forked from tsai/budibase
74 changed files with 1475 additions and 2586 deletions
@ -0,0 +1,25 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { makePropSafe } from "@budibase/string-templates" |
||||
|
import { currentAsset, store } from "builderStore" |
||||
|
import { findComponentPath } from "builderStore/storeUtils" |
||||
|
|
||||
|
export let value |
||||
|
|
||||
|
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId) |
||||
|
$: providers = path.filter( |
||||
|
component => |
||||
|
component._component === "@budibase/standard-components/dataprovider" |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<Select thin secondary {value} on:change> |
||||
|
<option value="">Choose option</option> |
||||
|
{#if providers} |
||||
|
{#each providers as component} |
||||
|
<option value={`{{ literal ${makePropSafe(component._id)} }}`}> |
||||
|
{component._instanceName} |
||||
|
</option> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
</Select> |
||||
@ -1,7 +1,14 @@ |
|||||
<div class="root">This action doesn't require any additional settings.</div> |
<script> |
||||
|
import { Body } from "@budibase/bbui" |
||||
|
</script> |
||||
|
|
||||
|
<div class="root"> |
||||
|
<Body small grey>This action doesn't require any additional settings.</Body> |
||||
|
</div> |
||||
|
|
||||
<style> |
<style> |
||||
.root { |
.root { |
||||
font-size: var(--font-size-s); |
max-width: 800px; |
||||
|
margin: 0 auto; |
||||
} |
} |
||||
</style> |
</style> |
||||
|
|||||
@ -1,7 +1,7 @@ |
|||||
<script> |
<script> |
||||
import DatasourceSelect from "./DatasourceSelect.svelte" |
import DataSourceSelect from "./DataSourceSelect.svelte" |
||||
|
|
||||
const otherSources = [{ name: "Custom", label: "Custom" }] |
const otherSources = [{ name: "Custom", label: "Custom" }] |
||||
</script> |
</script> |
||||
|
|
||||
<DatasourceSelect on:change {...$$props} showAllQueries={true} {otherSources} /> |
<DataSourceSelect on:change {...$$props} showAllQueries={true} {otherSources} /> |
||||
|
|||||
@ -0,0 +1,84 @@ |
|||||
|
import { writable, get } from "svelte/store" |
||||
|
import { notificationStore } from "./notification" |
||||
|
|
||||
|
export const createDataSourceStore = () => { |
||||
|
const store = writable([]) |
||||
|
|
||||
|
// Registers a new dataSource instance
|
||||
|
const registerDataSource = (dataSource, instanceId, refresh) => { |
||||
|
if (!dataSource || !instanceId || !refresh) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Create a list of all relevant dataSource IDs which would require that
|
||||
|
// this dataSource is refreshed
|
||||
|
let dataSourceIds = [] |
||||
|
|
||||
|
// Extract table ID
|
||||
|
if (dataSource.type === "table" || dataSource.type === "view") { |
||||
|
if (dataSource.tableId) { |
||||
|
dataSourceIds.push(dataSource.tableId) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Extract both table IDs from both sides of the relationship
|
||||
|
else if (dataSource.type === "link") { |
||||
|
if (dataSource.rowTableId) { |
||||
|
dataSourceIds.push(dataSource.rowTableId) |
||||
|
} |
||||
|
if (dataSource.tableId) { |
||||
|
dataSourceIds.push(dataSource.tableId) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Extract the dataSource ID (not the query ID) for queries
|
||||
|
else if (dataSource.type === "query") { |
||||
|
if (dataSource.dataSourceId) { |
||||
|
dataSourceIds.push(dataSource.dataSourceId) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Store configs for each relevant dataSource ID
|
||||
|
if (dataSourceIds.length) { |
||||
|
store.update(state => { |
||||
|
dataSourceIds.forEach(id => { |
||||
|
state.push({ |
||||
|
dataSourceId: id, |
||||
|
instanceId, |
||||
|
refresh, |
||||
|
}) |
||||
|
}) |
||||
|
return state |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Removes all registered dataSource instances belonging to a particular
|
||||
|
// instance ID
|
||||
|
const unregisterInstance = instanceId => { |
||||
|
store.update(state => { |
||||
|
return state.filter(instance => instance.instanceId !== instanceId) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Invalidates a specific dataSource ID by refreshing all instances
|
||||
|
// which depend on data from that dataSource
|
||||
|
const invalidateDataSource = dataSourceId => { |
||||
|
const relatedInstances = get(store).filter(instance => { |
||||
|
return instance.dataSourceId === dataSourceId |
||||
|
}) |
||||
|
if (relatedInstances?.length) { |
||||
|
notificationStore.blockNotifications(1000) |
||||
|
} |
||||
|
relatedInstances?.forEach(instance => { |
||||
|
instance.refresh() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: store.subscribe, |
||||
|
actions: { registerDataSource, unregisterInstance, invalidateDataSource }, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const dataSourceStore = createDataSourceStore() |
||||
@ -1,84 +0,0 @@ |
|||||
import { writable, get } from "svelte/store" |
|
||||
import { notificationStore } from "./notification" |
|
||||
|
|
||||
export const createDatasourceStore = () => { |
|
||||
const store = writable([]) |
|
||||
|
|
||||
// Registers a new datasource instance
|
|
||||
const registerDatasource = (datasource, instanceId, refresh) => { |
|
||||
if (!datasource || !instanceId || !refresh) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// Create a list of all relevant datasource IDs which would require that
|
|
||||
// this datasource is refreshed
|
|
||||
let datasourceIds = [] |
|
||||
|
|
||||
// Extract table ID
|
|
||||
if (datasource.type === "table" || datasource.type === "view") { |
|
||||
if (datasource.tableId) { |
|
||||
datasourceIds.push(datasource.tableId) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Extract both table IDs from both sides of the relationship
|
|
||||
else if (datasource.type === "link") { |
|
||||
if (datasource.rowTableId) { |
|
||||
datasourceIds.push(datasource.rowTableId) |
|
||||
} |
|
||||
if (datasource.tableId) { |
|
||||
datasourceIds.push(datasource.tableId) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Extract the datasource ID (not the query ID) for queries
|
|
||||
else if (datasource.type === "query") { |
|
||||
if (datasource.datasourceId) { |
|
||||
datasourceIds.push(datasource.datasourceId) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Store configs for each relevant datasource ID
|
|
||||
if (datasourceIds.length) { |
|
||||
store.update(state => { |
|
||||
datasourceIds.forEach(id => { |
|
||||
state.push({ |
|
||||
datasourceId: id, |
|
||||
instanceId, |
|
||||
refresh, |
|
||||
}) |
|
||||
}) |
|
||||
return state |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Removes all registered datasource instances belonging to a particular
|
|
||||
// instance ID
|
|
||||
const unregisterInstance = instanceId => { |
|
||||
store.update(state => { |
|
||||
return state.filter(instance => instance.instanceId !== instanceId) |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
// Invalidates a specific datasource ID by refreshing all instances
|
|
||||
// which depend on data from that datasource
|
|
||||
const invalidateDatasource = datasourceId => { |
|
||||
const relatedInstances = get(store).filter(instance => { |
|
||||
return instance.datasourceId === datasourceId |
|
||||
}) |
|
||||
if (relatedInstances?.length) { |
|
||||
notificationStore.blockNotifications(1000) |
|
||||
} |
|
||||
relatedInstances?.forEach(instance => { |
|
||||
instance.refresh() |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
subscribe: store.subscribe, |
|
||||
actions: { registerDatasource, unregisterInstance, invalidateDatasource }, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export const datasourceStore = createDatasourceStore() |
|
||||
File diff suppressed because it is too large
@ -0,0 +1,108 @@ |
|||||
|
<script> |
||||
|
import { getContext } from "svelte" |
||||
|
|
||||
|
export let dataSource |
||||
|
export let filter |
||||
|
export let sortColumn |
||||
|
export let sortOrder |
||||
|
export let limit |
||||
|
|
||||
|
const { API, styleable, Provider, ActionTypes } = getContext("sdk") |
||||
|
const component = getContext("component") |
||||
|
|
||||
|
// Loading flag every time data is being fetched |
||||
|
let loading = false |
||||
|
|
||||
|
// Loading flag for the initial load |
||||
|
let loaded = false |
||||
|
|
||||
|
let allRows = [] |
||||
|
let schema = {} |
||||
|
|
||||
|
$: fetchData(dataSource) |
||||
|
$: filteredRows = filterRows(allRows, filter) |
||||
|
$: sortedRows = sortRows(filteredRows, sortColumn, sortOrder) |
||||
|
$: rows = limitRows(sortedRows, limit) |
||||
|
$: getSchema(dataSource) |
||||
|
$: actions = [ |
||||
|
{ |
||||
|
type: ActionTypes.RefreshDatasource, |
||||
|
callback: () => fetchData(dataSource), |
||||
|
metadata: { dataSource }, |
||||
|
}, |
||||
|
] |
||||
|
$: dataContext = { |
||||
|
rows, |
||||
|
schema, |
||||
|
rowsLength: rows.length, |
||||
|
loading, |
||||
|
loaded, |
||||
|
} |
||||
|
|
||||
|
const fetchData = async dataSource => { |
||||
|
loading = true |
||||
|
allRows = await API.fetchDatasource(dataSource) |
||||
|
loading = false |
||||
|
loaded = true |
||||
|
} |
||||
|
|
||||
|
const filterRows = (rows, filter) => { |
||||
|
if (!Object.keys(filter || {}).length) { |
||||
|
return rows |
||||
|
} |
||||
|
let filteredData = [...rows] |
||||
|
Object.entries(filter).forEach(([field, value]) => { |
||||
|
if (value != null && value !== "") { |
||||
|
filteredData = filteredData.filter(row => { |
||||
|
return row[field] === value |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
return filteredData |
||||
|
} |
||||
|
|
||||
|
const sortRows = (rows, sortColumn, sortOrder) => { |
||||
|
if (!sortColumn || !sortOrder) { |
||||
|
return rows |
||||
|
} |
||||
|
return rows.slice().sort((a, b) => { |
||||
|
const colA = a[sortColumn] |
||||
|
const colB = b[sortColumn] |
||||
|
if (sortOrder === "Descending") { |
||||
|
return colA > colB ? -1 : 1 |
||||
|
} else { |
||||
|
return colA > colB ? 1 : -1 |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const limitRows = (rows, limit) => { |
||||
|
const numLimit = parseFloat(limit) |
||||
|
if (isNaN(numLimit)) { |
||||
|
return rows |
||||
|
} |
||||
|
return rows.slice(0, numLimit) |
||||
|
} |
||||
|
|
||||
|
const getSchema = async dataSource => { |
||||
|
if (dataSource?.schema) { |
||||
|
schema = dataSource.schema |
||||
|
} else if (dataSource?.tableId) { |
||||
|
const definition = await API.fetchTableDefinition(dataSource.tableId) |
||||
|
schema = definition?.schema ?? {} |
||||
|
} else { |
||||
|
schema = {} |
||||
|
} |
||||
|
|
||||
|
// Ensure all schema fields have a name property |
||||
|
Object.entries(schema).forEach(([key, value]) => { |
||||
|
if (!value.name) { |
||||
|
value.name = key |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Provider {actions} data={dataContext}> |
||||
|
<slot /> |
||||
|
</Provider> |
||||
@ -1,83 +0,0 @@ |
|||||
<script> |
|
||||
import { getContext } from "svelte" |
|
||||
import { isEmpty } from "lodash/fp" |
|
||||
|
|
||||
export let datasource |
|
||||
export let noRowsMessage |
|
||||
export let filter |
|
||||
|
|
||||
const { API, styleable, Provider, builderStore, ActionTypes } = getContext( |
|
||||
"sdk" |
|
||||
) |
|
||||
const component = getContext("component") |
|
||||
let rows = [] |
|
||||
let loaded = false |
|
||||
|
|
||||
$: fetchData(datasource) |
|
||||
$: filteredRows = filterRows(rows, filter) |
|
||||
$: actions = [ |
|
||||
{ |
|
||||
type: ActionTypes.RefreshDatasource, |
|
||||
callback: () => fetchData(datasource), |
|
||||
metadata: { datasource }, |
|
||||
}, |
|
||||
] |
|
||||
|
|
||||
const fetchData = async datasource => { |
|
||||
if (!isEmpty(datasource)) { |
|
||||
rows = await API.fetchDatasource(datasource) |
|
||||
} |
|
||||
loaded = true |
|
||||
} |
|
||||
|
|
||||
const filterRows = (rows, filter) => { |
|
||||
if (!Object.keys(filter || {}).length) { |
|
||||
return rows |
|
||||
} |
|
||||
let filteredData = [...rows] |
|
||||
Object.entries(filter).forEach(([field, value]) => { |
|
||||
if (value != null && value !== "") { |
|
||||
filteredData = filteredData.filter(row => { |
|
||||
return row[field] === value |
|
||||
}) |
|
||||
} |
|
||||
}) |
|
||||
return filteredData |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<Provider {actions}> |
|
||||
<div use:styleable={$component.styles}> |
|
||||
{#if filteredRows.length > 0} |
|
||||
{#if $component.children === 0 && $builderStore.inBuilder} |
|
||||
<p><i class="ri-image-line" />Add some components to display.</p> |
|
||||
{:else} |
|
||||
{#each filteredRows as row} |
|
||||
<Provider data={row}> |
|
||||
<slot /> |
|
||||
</Provider> |
|
||||
{/each} |
|
||||
{/if} |
|
||||
{:else if loaded && noRowsMessage} |
|
||||
<p><i class="ri-list-check-2" />{noRowsMessage}</p> |
|
||||
{/if} |
|
||||
</div> |
|
||||
</Provider> |
|
||||
|
|
||||
<style> |
|
||||
p { |
|
||||
margin: 0 var(--spacing-m); |
|
||||
background-color: var(--grey-2); |
|
||||
color: var(--grey-6); |
|
||||
font-size: var(--font-size-s); |
|
||||
padding: var(--spacing-l); |
|
||||
border-radius: var(--border-radius-s); |
|
||||
display: grid; |
|
||||
place-items: center; |
|
||||
} |
|
||||
p i { |
|
||||
margin-bottom: var(--spacing-m); |
|
||||
font-size: 1.5rem; |
|
||||
color: var(--grey-5); |
|
||||
} |
|
||||
</style> |
|
||||
@ -0,0 +1,47 @@ |
|||||
|
<script> |
||||
|
import { getContext } from "svelte" |
||||
|
|
||||
|
export let dataProvider |
||||
|
export let noRowsMessage |
||||
|
|
||||
|
const { API, styleable, builderStore, Provider } = getContext("sdk") |
||||
|
const component = getContext("component") |
||||
|
const context = getContext("context") |
||||
|
|
||||
|
$: rows = dataProvider?.rows ?? [] |
||||
|
$: loaded = dataProvider?.loaded ?? false |
||||
|
</script> |
||||
|
|
||||
|
<div use:styleable={$component.styles}> |
||||
|
{#if rows.length > 0} |
||||
|
{#if $component.children === 0 && $builderStore.inBuilder} |
||||
|
<p><i class="ri-image-line" />Add some components to display.</p> |
||||
|
{:else} |
||||
|
{#each rows as row} |
||||
|
<Provider data={row}> |
||||
|
<slot /> |
||||
|
</Provider> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
{:else if loaded && noRowsMessage} |
||||
|
<p><i class="ri-list-check-2" />{noRowsMessage}</p> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
p { |
||||
|
margin: 0 var(--spacing-m); |
||||
|
background-color: var(--grey-2); |
||||
|
color: var(--grey-6); |
||||
|
font-size: var(--font-size-s); |
||||
|
padding: var(--spacing-l); |
||||
|
border-radius: var(--border-radius-s); |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
p i { |
||||
|
margin-bottom: var(--spacing-m); |
||||
|
font-size: 1.5rem; |
||||
|
color: var(--grey-5); |
||||
|
} |
||||
|
</style> |
||||
@ -1,57 +0,0 @@ |
|||||
<script> |
|
||||
import { onMount, getContext } from "svelte" |
|
||||
|
|
||||
export let table |
|
||||
|
|
||||
const { |
|
||||
API, |
|
||||
screenStore, |
|
||||
routeStore, |
|
||||
Provider, |
|
||||
styleable, |
|
||||
ActionTypes, |
|
||||
} = getContext("sdk") |
|
||||
const component = getContext("component") |
|
||||
let headers = [] |
|
||||
let row |
|
||||
|
|
||||
const fetchFirstRow = async tableId => { |
|
||||
const rows = await API.fetchTableData(tableId) |
|
||||
return Array.isArray(rows) && rows.length ? rows[0] : { tableId } |
|
||||
} |
|
||||
|
|
||||
const fetchData = async (rowId, tableId) => { |
|
||||
if (!tableId) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const pathParts = window.location.pathname.split("/") |
|
||||
|
|
||||
// if srcdoc, then we assume this is the builder preview |
|
||||
if ((pathParts.length === 0 || pathParts[0] === "srcdoc") && tableId) { |
|
||||
row = await fetchFirstRow(tableId) |
|
||||
} else if (rowId) { |
|
||||
row = await API.fetchRow({ tableId, rowId }) |
|
||||
} else { |
|
||||
throw new Error("Row ID was not supplied to RowDetail") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
$: actions = [ |
|
||||
{ |
|
||||
type: ActionTypes.RefreshDatasource, |
|
||||
callback: () => fetchData($routeStore.routeParams.id, table), |
|
||||
metadata: { datasource: { type: "table", tableId: table } }, |
|
||||
}, |
|
||||
] |
|
||||
|
|
||||
onMount(() => fetchData($routeStore.routeParams.id, table)) |
|
||||
</script> |
|
||||
|
|
||||
{#if row} |
|
||||
<Provider data={row} {actions}> |
|
||||
<div use:styleable={$component.styles}> |
|
||||
<slot /> |
|
||||
</div> |
|
||||
</Provider> |
|
||||
{/if} |
|
||||
@ -1,6 +0,0 @@ |
|||||
<script> |
|
||||
import AttachmentList from "../../attachments/AttachmentList.svelte" |
|
||||
export let files |
|
||||
</script> |
|
||||
|
|
||||
<AttachmentList {files} on:delete /> |
|
||||
@ -1,193 +0,0 @@ |
|||||
<script> |
|
||||
// Import valueSetters and custom renderers |
|
||||
import { number } from "./valueSetters" |
|
||||
import { getRenderer } from "./customRenderer" |
|
||||
import { isEmpty } from "lodash/fp" |
|
||||
import { getContext } from "svelte" |
|
||||
import AgGrid from "@budibase/svelte-ag-grid" |
|
||||
import { |
|
||||
TextButton as DeleteButton, |
|
||||
Icon, |
|
||||
Modal, |
|
||||
ModalContent, |
|
||||
} from "@budibase/bbui" |
|
||||
|
|
||||
// These maps need to be set up to handle whatever types that are used in the tables. |
|
||||
const setters = new Map([["number", number]]) |
|
||||
const SDK = getContext("sdk") |
|
||||
const component = getContext("component") |
|
||||
const { API, styleable } = SDK |
|
||||
|
|
||||
export let datasource = {} |
|
||||
export let editable |
|
||||
export let theme = "alpine" |
|
||||
export let height = 500 |
|
||||
export let pagination |
|
||||
export let detailUrl |
|
||||
|
|
||||
// Add setting height as css var to allow grid to use correct height |
|
||||
$: gridStyles = { |
|
||||
...$component.styles, |
|
||||
normal: { |
|
||||
...$component.styles.normal, |
|
||||
["--grid-height"]: `${height}px`, |
|
||||
}, |
|
||||
} |
|
||||
$: fetchData(datasource) |
|
||||
|
|
||||
// These can never change at runtime so don't need to be reactive |
|
||||
let canEdit = editable && datasource && datasource.type !== "view" |
|
||||
let canAddDelete = editable && datasource && datasource.type === "table" |
|
||||
|
|
||||
let modal |
|
||||
let dataLoaded = false |
|
||||
let data |
|
||||
let columnDefs |
|
||||
let selectedRows = [] |
|
||||
let table |
|
||||
let options = { |
|
||||
defaultColDef: { |
|
||||
flex: 1, |
|
||||
minWidth: 150, |
|
||||
filter: true, |
|
||||
}, |
|
||||
rowSelection: canEdit ? "multiple" : false, |
|
||||
suppressFieldDotNotation: true, |
|
||||
suppressRowClickSelection: !canEdit, |
|
||||
paginationAutoPageSize: true, |
|
||||
pagination, |
|
||||
} |
|
||||
|
|
||||
async function fetchData(datasource) { |
|
||||
if (isEmpty(datasource)) { |
|
||||
return |
|
||||
} |
|
||||
data = await API.fetchDatasource(datasource) |
|
||||
|
|
||||
let schema |
|
||||
|
|
||||
// Get schema for datasource |
|
||||
// Views with "Calculate" applied provide their own schema. |
|
||||
// For everything else, use the tableId property to pull to table schema |
|
||||
if (datasource.schema) { |
|
||||
schema = datasource.schema |
|
||||
} else { |
|
||||
schema = (await API.fetchTableDefinition(datasource.tableId)).schema |
|
||||
} |
|
||||
|
|
||||
columnDefs = Object.keys(schema).map((key, i) => { |
|
||||
return { |
|
||||
headerCheckboxSelection: i === 0 && canEdit, |
|
||||
checkboxSelection: i === 0 && canEdit, |
|
||||
valueSetter: setters.get(schema[key].type), |
|
||||
headerName: key, |
|
||||
field: key, |
|
||||
hide: shouldHideField(key), |
|
||||
sortable: true, |
|
||||
editable: canEdit && schema[key].type !== "link", |
|
||||
cellRenderer: getRenderer(schema[key], canEdit, SDK), |
|
||||
autoHeight: true, |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
if (detailUrl) { |
|
||||
columnDefs = [ |
|
||||
...columnDefs, |
|
||||
{ |
|
||||
headerName: "Detail", |
|
||||
field: "_id", |
|
||||
minWidth: 100, |
|
||||
width: 100, |
|
||||
flex: 0, |
|
||||
editable: false, |
|
||||
sortable: false, |
|
||||
cellRenderer: getRenderer( |
|
||||
{ |
|
||||
type: "_id", |
|
||||
options: { detailUrl }, |
|
||||
}, |
|
||||
false, |
|
||||
SDK |
|
||||
), |
|
||||
autoHeight: true, |
|
||||
pinned: "left", |
|
||||
filter: false, |
|
||||
}, |
|
||||
] |
|
||||
} |
|
||||
|
|
||||
dataLoaded = true |
|
||||
} |
|
||||
|
|
||||
const shouldHideField = name => { |
|
||||
if (name.startsWith("_")) return true |
|
||||
// always 'row' |
|
||||
if (name === "type") return true |
|
||||
// tables are always tied to a single tableId, this is irrelevant |
|
||||
if (name === "tableId") return true |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
const handleUpdate = ({ detail }) => { |
|
||||
data[detail.row] = detail.data |
|
||||
updateRow(detail.data) |
|
||||
} |
|
||||
|
|
||||
const updateRow = async row => { |
|
||||
await API.updateRow(row) |
|
||||
} |
|
||||
|
|
||||
const deleteRows = async () => { |
|
||||
await API.deleteRows({ rows: selectedRows, tableId: datasource.name }) |
|
||||
data = data.filter(row => !selectedRows.includes(row)) |
|
||||
selectedRows = [] |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<div class="container" use:styleable={gridStyles}> |
|
||||
{#if dataLoaded} |
|
||||
{#if canAddDelete} |
|
||||
<div class="controls"> |
|
||||
{#if selectedRows.length > 0} |
|
||||
<DeleteButton text small on:click={modal.show()}> |
|
||||
<Icon name="addrow" /> |
|
||||
Delete |
|
||||
{selectedRows.length} |
|
||||
row(s) |
|
||||
</DeleteButton> |
|
||||
{/if} |
|
||||
</div> |
|
||||
{/if} |
|
||||
<AgGrid |
|
||||
{theme} |
|
||||
{options} |
|
||||
{data} |
|
||||
{columnDefs} |
|
||||
on:update={handleUpdate} |
|
||||
on:select={({ detail }) => (selectedRows = detail)} /> |
|
||||
{/if} |
|
||||
<Modal bind:this={modal}> |
|
||||
<ModalContent |
|
||||
title="Confirm Row Deletion" |
|
||||
confirmText="Delete" |
|
||||
onConfirm={deleteRows}> |
|
||||
<span>Are you sure you want to delete {selectedRows.length} row(s)?</span> |
|
||||
</ModalContent> |
|
||||
</Modal> |
|
||||
</div> |
|
||||
|
|
||||
<style> |
|
||||
.container :global(.ag-pinned-left-header .ag-header-cell-label) { |
|
||||
justify-content: center; |
|
||||
} |
|
||||
|
|
||||
.controls { |
|
||||
min-height: 15px; |
|
||||
margin-bottom: var(--spacing-s); |
|
||||
display: grid; |
|
||||
grid-gap: var(--spacing-s); |
|
||||
grid-template-columns: auto auto; |
|
||||
justify-content: start; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,37 +0,0 @@ |
|||||
<script> |
|
||||
import { createEventDispatcher } from "svelte" |
|
||||
import { DropdownMenu, TextButton as Button, Icon } from "@budibase/bbui" |
|
||||
import Modal from "./Modal.svelte" |
|
||||
|
|
||||
const dispatch = createEventDispatcher() |
|
||||
|
|
||||
let anchor |
|
||||
let dropdown |
|
||||
|
|
||||
export let table |
|
||||
</script> |
|
||||
|
|
||||
<div bind:this={anchor}> |
|
||||
<Button text small on:click={dropdown.show}> |
|
||||
<Icon name="addrow" /> |
|
||||
Create New Row |
|
||||
</Button> |
|
||||
</div> |
|
||||
<DropdownMenu bind:this={dropdown} {anchor} align="left"> |
|
||||
<h5>Add New Row</h5> |
|
||||
<Modal |
|
||||
{table} |
|
||||
onClosed={dropdown.hide} |
|
||||
on:newRow={() => dispatch('newRow')} /> |
|
||||
</DropdownMenu> |
|
||||
|
|
||||
<style> |
|
||||
div { |
|
||||
display: grid; |
|
||||
} |
|
||||
h5 { |
|
||||
padding: var(--spacing-xl) 0 0 var(--spacing-xl); |
|
||||
margin: 0; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,144 +0,0 @@ |
|||||
<script> |
|
||||
import { getContext, onMount, createEventDispatcher } from "svelte" |
|
||||
import { Button, Label, DatePicker, RichText } from "@budibase/bbui" |
|
||||
import Dropzone from "../../attachments/Dropzone.svelte" |
|
||||
import debounce from "lodash.debounce" |
|
||||
|
|
||||
const dispatch = createEventDispatcher() |
|
||||
const { fetchRow, saveRow, routeStore } = getContext("sdk") |
|
||||
|
|
||||
const DEFAULTS_FOR_TYPE = { |
|
||||
string: "", |
|
||||
boolean: false, |
|
||||
number: null, |
|
||||
link: [], |
|
||||
} |
|
||||
|
|
||||
export let table |
|
||||
export let onClosed |
|
||||
|
|
||||
let row = { tableId: table._id } |
|
||||
let schema = table.schema |
|
||||
let saved = false |
|
||||
let rowId |
|
||||
let isNew = true |
|
||||
let errors = {} |
|
||||
|
|
||||
$: fields = schema ? Object.keys(schema) : [] |
|
||||
|
|
||||
$: errorMessages = Object.entries(errors).map( |
|
||||
([field, message]) => `${field} ${message}` |
|
||||
) |
|
||||
|
|
||||
const save = debounce(async () => { |
|
||||
for (let field of fields) { |
|
||||
// Assign defaults to empty fields to prevent validation issues |
|
||||
if (!(field in row)) { |
|
||||
row[field] = DEFAULTS_FOR_TYPE[schema[field].type] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const response = await saveRow(row) |
|
||||
|
|
||||
if (!response.error) { |
|
||||
// store.update(state => { |
|
||||
// state[table._id] = state[table._id] |
|
||||
// ? [...state[table._id], json] |
|
||||
// : [json] |
|
||||
// return state |
|
||||
// }) |
|
||||
|
|
||||
errors = {} |
|
||||
|
|
||||
// wipe form, if new row, otherwise update |
|
||||
// table to get new _rev |
|
||||
row = isNew ? { tableId: table._id } : response |
|
||||
|
|
||||
onClosed() |
|
||||
dispatch("newRow") |
|
||||
} else { |
|
||||
errors = [response.error] |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
onMount(async () => { |
|
||||
const routeParams = $routeStore.routeParams |
|
||||
rowId = |
|
||||
Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0]) |
|
||||
isNew = !rowId || rowId === "new" |
|
||||
|
|
||||
if (isNew) { |
|
||||
row = { tableId: table } |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
row = await fetchRow({ tableId: table._id, rowId }) |
|
||||
}) |
|
||||
</script> |
|
||||
|
|
||||
<div class="actions"> |
|
||||
{#each errorMessages as error} |
|
||||
<p class="error">{error}</p> |
|
||||
{/each} |
|
||||
<form on:submit|preventDefault> |
|
||||
{#each fields as field} |
|
||||
<div class="form-item"> |
|
||||
<Label small forAttr={'form-stacked-text'}>{field}</Label> |
|
||||
{#if schema[field].type === 'string' && schema[field].constraints.inclusion} |
|
||||
<select bind:value={row[field]}> |
|
||||
{#each schema[field].constraints.inclusion as opt} |
|
||||
<option>{opt}</option> |
|
||||
{/each} |
|
||||
</select> |
|
||||
{:else if schema[field].type === 'datetime'} |
|
||||
<DatePicker bind:value={row[field]} /> |
|
||||
{:else if schema[field].type === 'boolean'} |
|
||||
<input class="input" type="checkbox" bind:checked={row[field]} /> |
|
||||
{:else if schema[field].type === 'number'} |
|
||||
<input class="input" type="number" bind:value={row[field]} /> |
|
||||
{:else if schema[field].type === 'string'} |
|
||||
<input class="input" type="text" bind:value={row[field]} /> |
|
||||
{:else if schema[field].type === 'longform'} |
|
||||
<RichText bind:value={row[field]} /> |
|
||||
{:else if schema[field].type === 'attachment'} |
|
||||
<Dropzone bind:files={row[field]} /> |
|
||||
{/if} |
|
||||
</div> |
|
||||
<hr /> |
|
||||
{/each} |
|
||||
</form> |
|
||||
</div> |
|
||||
<footer> |
|
||||
<div class="button-margin-3"> |
|
||||
<Button secondary on:click={onClosed}>Cancel</Button> |
|
||||
</div> |
|
||||
<div class="button-margin-4"> |
|
||||
<Button primary on:click={save}>Save</Button> |
|
||||
</div> |
|
||||
</footer> |
|
||||
|
|
||||
<style> |
|
||||
.actions { |
|
||||
padding: var(--spacing-l) var(--spacing-xl); |
|
||||
} |
|
||||
|
|
||||
footer { |
|
||||
padding: 20px 30px; |
|
||||
display: grid; |
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr; |
|
||||
gap: 20px; |
|
||||
background: var(--grey-1); |
|
||||
border-bottom-left-radius: 0.5rem; |
|
||||
border-bottom-left-radius: 0.5rem; |
|
||||
} |
|
||||
|
|
||||
.button-margin-3 { |
|
||||
grid-column-start: 3; |
|
||||
display: grid; |
|
||||
} |
|
||||
|
|
||||
.button-margin-4 { |
|
||||
grid-column-start: 4; |
|
||||
display: grid; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,5 +0,0 @@ |
|||||
<script> |
|
||||
import { DatePicker } from "@budibase/bbui" |
|
||||
</script> |
|
||||
|
|
||||
<DatePicker /> |
|
||||
@ -1,75 +0,0 @@ |
|||||
<script> |
|
||||
import { onMount } from "svelte" |
|
||||
|
|
||||
export let columnName |
|
||||
export let row |
|
||||
export let SDK |
|
||||
|
|
||||
const { API } = SDK |
|
||||
|
|
||||
$: count = |
|
||||
row && columnName && Array.isArray(row[columnName]) |
|
||||
? row[columnName].length |
|
||||
: 0 |
|
||||
let linkedRows = [] |
|
||||
let displayColumn |
|
||||
|
|
||||
onMount(async () => { |
|
||||
linkedRows = await API.fetchRelationshipData({ |
|
||||
tableId: row.tableId, |
|
||||
rowId: row._id, |
|
||||
fieldName: columnName, |
|
||||
}) |
|
||||
if (linkedRows && linkedRows.length) { |
|
||||
const table = await API.fetchTableDefinition(linkedRows[0].tableId) |
|
||||
if (table && table.primaryDisplay) { |
|
||||
displayColumn = table.primaryDisplay |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
async function fetchLinkedRowsData(row, columnName) { |
|
||||
if (!row || !row._id) { |
|
||||
return [] |
|
||||
} |
|
||||
return await API.fetchRelationshipData({ |
|
||||
tableId: row.tableId, |
|
||||
rowId: row._id, |
|
||||
fieldName: columnName, |
|
||||
}) |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<div class="container"> |
|
||||
{#if linkedRows && linkedRows.length && displayColumn} |
|
||||
{#each linkedRows as linkedRow} |
|
||||
{#if linkedRow[displayColumn] != null && linkedRow[displayColumn] !== ''} |
|
||||
<div class="linked-row">{linkedRow[displayColumn]}</div> |
|
||||
{/if} |
|
||||
{/each} |
|
||||
{:else}{count} related row(s){/if} |
|
||||
</div> |
|
||||
|
|
||||
<style> |
|
||||
.container { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-start; |
|
||||
align-items: center; |
|
||||
gap: var(--spacing-xs); |
|
||||
width: 100%; |
|
||||
} |
|
||||
|
|
||||
/* This styling is opinionated to ensure these always look consistent */ |
|
||||
.linked-row { |
|
||||
color: white; |
|
||||
background-color: #616161; |
|
||||
border-radius: var(--border-radius-xs); |
|
||||
padding: var(--spacing-xs) var(--spacing-s) calc(var(--spacing-xs) + 1px) |
|
||||
var(--spacing-s); |
|
||||
line-height: 1; |
|
||||
font-size: 0.8em; |
|
||||
font-family: var(--font-sans); |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,32 +0,0 @@ |
|||||
<script> |
|
||||
export let columnName |
|
||||
export let row |
|
||||
|
|
||||
$: items = row?.[columnName] || [] |
|
||||
</script> |
|
||||
|
|
||||
<div class="container"> |
|
||||
{#each items as item} |
|
||||
<div class="item">{item?.primaryDisplay ?? ''}</div> |
|
||||
{/each} |
|
||||
</div> |
|
||||
|
|
||||
<style> |
|
||||
.container { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-start; |
|
||||
align-items: center; |
|
||||
gap: var(--spacing-xs); |
|
||||
width: 100%; |
|
||||
} |
|
||||
|
|
||||
.item { |
|
||||
font-size: var(--font-size-xs); |
|
||||
padding: var(--spacing-xs) var(--spacing-s); |
|
||||
border: 1px solid var(--grey-5); |
|
||||
color: var(--grey-7); |
|
||||
line-height: normal; |
|
||||
border-radius: 4px; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,17 +0,0 @@ |
|||||
<script> |
|
||||
import { Select } from "@budibase/bbui" |
|
||||
import { createEventDispatcher } from "svelte" |
|
||||
const dispatch = createEventDispatcher() |
|
||||
|
|
||||
export let value |
|
||||
export let options |
|
||||
|
|
||||
$: dispatch("change", value) |
|
||||
</script> |
|
||||
|
|
||||
<Select label={false} bind:value> |
|
||||
<option value="">Choose an option</option> |
|
||||
{#each options as option} |
|
||||
<option value={option}>{option}</option> |
|
||||
{/each} |
|
||||
</Select> |
|
||||
@ -1,19 +0,0 @@ |
|||||
<script> |
|
||||
import { Button } from "@budibase/bbui" |
|
||||
|
|
||||
export let url |
|
||||
export let SDK |
|
||||
|
|
||||
const { linkable } = SDK |
|
||||
|
|
||||
let link |
|
||||
</script> |
|
||||
|
|
||||
<a href={url} bind:this={link} use:linkable /> |
|
||||
<Button small translucent on:click={() => link.click()}>View</Button> |
|
||||
|
|
||||
<style> |
|
||||
a { |
|
||||
display: none; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,169 +0,0 @@ |
|||||
// Custom renderers to handle special types
|
|
||||
// https://www.ag-grid.com/javascript-grid-cell-rendering-components/
|
|
||||
|
|
||||
import AttachmentCell from "./AttachmentCell/Button.svelte" |
|
||||
import ViewDetails from "./ViewDetails/Cell.svelte" |
|
||||
import Select from "./Select/Wrapper.svelte" |
|
||||
import DatePicker from "./DateTime/Wrapper.svelte" |
|
||||
import RelationshipLabel from "./Relationship/RelationshipLabel.svelte" |
|
||||
|
|
||||
const renderers = new Map([ |
|
||||
["boolean", booleanRenderer], |
|
||||
["attachment", attachmentRenderer], |
|
||||
["options", optionsRenderer], |
|
||||
["link", linkedRowRenderer], |
|
||||
["_id", viewDetailsRenderer], |
|
||||
]) |
|
||||
|
|
||||
export function getRenderer(schema, editable, SDK) { |
|
||||
if (renderers.get(schema.type)) { |
|
||||
return renderers.get(schema.type)( |
|
||||
schema.options, |
|
||||
schema.constraints, |
|
||||
editable, |
|
||||
SDK |
|
||||
) |
|
||||
} else { |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/* eslint-disable no-unused-vars */ |
|
||||
function booleanRenderer(options, constraints, editable, SDK) { |
|
||||
return params => { |
|
||||
const toggle = e => { |
|
||||
params.value = !params.value |
|
||||
params.setValue(e.currentTarget.checked) |
|
||||
} |
|
||||
let input = document.createElement("input") |
|
||||
input.style.display = "grid" |
|
||||
input.style.placeItems = "center" |
|
||||
input.style.height = "100%" |
|
||||
input.type = "checkbox" |
|
||||
input.checked = params.value |
|
||||
if (editable) { |
|
||||
input.addEventListener("click", toggle) |
|
||||
} else { |
|
||||
input.disabled = true |
|
||||
} |
|
||||
|
|
||||
return input |
|
||||
} |
|
||||
} |
|
||||
/* eslint-disable no-unused-vars */ |
|
||||
function attachmentRenderer(options, constraints, editable, SDK) { |
|
||||
return params => { |
|
||||
const container = document.createElement("div") |
|
||||
|
|
||||
const attachmentInstance = new AttachmentCell({ |
|
||||
target: container, |
|
||||
props: { |
|
||||
files: params.value || [], |
|
||||
SDK, |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
const deleteFile = event => { |
|
||||
const newFilesArray = params.value.filter(file => file !== event.detail) |
|
||||
params.setValue(newFilesArray) |
|
||||
} |
|
||||
|
|
||||
attachmentInstance.$on("delete", deleteFile) |
|
||||
|
|
||||
return container |
|
||||
} |
|
||||
} |
|
||||
/* eslint-disable no-unused-vars */ |
|
||||
function dateRenderer(options, constraints, editable, SDK) { |
|
||||
return function(params) { |
|
||||
const container = document.createElement("div") |
|
||||
const toggle = e => { |
|
||||
params.setValue(e.detail[0][0]) |
|
||||
} |
|
||||
|
|
||||
// Options need to be passed in with minTime and maxTime! Needs bbui update.
|
|
||||
new DatePicker({ |
|
||||
target: container, |
|
||||
props: { |
|
||||
value: params.value, |
|
||||
SDK, |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
return container |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function optionsRenderer(options, constraints, editable, SDK) { |
|
||||
return params => { |
|
||||
if (!editable) return params.value |
|
||||
const container = document.createElement("div") |
|
||||
container.style.display = "grid" |
|
||||
container.style.placeItems = "center" |
|
||||
container.style.height = "100%" |
|
||||
const change = e => { |
|
||||
params.setValue(e.detail) |
|
||||
} |
|
||||
|
|
||||
const selectInstance = new Select({ |
|
||||
target: container, |
|
||||
props: { |
|
||||
value: params.value, |
|
||||
options: constraints.inclusion, |
|
||||
SDK, |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
selectInstance.$on("change", change) |
|
||||
|
|
||||
return container |
|
||||
} |
|
||||
} |
|
||||
/* eslint-disable no-unused-vars */ |
|
||||
function linkedRowRenderer(options, constraints, editable, SDK) { |
|
||||
return params => { |
|
||||
let container = document.createElement("div") |
|
||||
container.style.display = "grid" |
|
||||
container.style.placeItems = "center" |
|
||||
container.style.height = "100%" |
|
||||
|
|
||||
new RelationshipLabel({ |
|
||||
target: container, |
|
||||
props: { |
|
||||
row: params.data, |
|
||||
columnName: params.column.colId, |
|
||||
SDK, |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
return container |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/* eslint-disable no-unused-vars */ |
|
||||
function viewDetailsRenderer(options, constraints, editable, SDK) { |
|
||||
return params => { |
|
||||
let container = document.createElement("div") |
|
||||
container.style.display = "grid" |
|
||||
container.style.alignItems = "center" |
|
||||
container.style.height = "100%" |
|
||||
|
|
||||
let url = "/" |
|
||||
if (options.detailUrl) { |
|
||||
url = options.detailUrl.replace(":id", params.data._id) |
|
||||
} |
|
||||
if (!url.startsWith("/")) { |
|
||||
url = `/${url}` |
|
||||
} |
|
||||
|
|
||||
new ViewDetails({ |
|
||||
target: container, |
|
||||
props: { |
|
||||
url, |
|
||||
SDK, |
|
||||
}, |
|
||||
}) |
|
||||
|
|
||||
return container |
|
||||
} |
|
||||
} |
|
||||
@ -1,6 +0,0 @@ |
|||||
// https://www.ag-grid.com/javascript-grid-value-setters/
|
|
||||
// These handles values and makes sure they adhere to the data type provided by the table
|
|
||||
export const number = params => { |
|
||||
params.data[params.colDef.field] = parseFloat(params.newValue) |
|
||||
return true |
|
||||
} |
|
||||
@ -0,0 +1,39 @@ |
|||||
|
<script> |
||||
|
export let value |
||||
|
|
||||
|
const displayLimit = 5 |
||||
|
$: attachments = value?.slice(0, displayLimit) ?? [] |
||||
|
$: leftover = (value?.length ?? 0) - attachments.length |
||||
|
</script> |
||||
|
|
||||
|
{#each attachments as attachment} |
||||
|
{#if attachment.type.startsWith('image')} |
||||
|
<img src={attachment.url} alt={attachment.extension} /> |
||||
|
{:else} |
||||
|
<div class="file">{attachment.extension}</div> |
||||
|
{/if} |
||||
|
{/each} |
||||
|
{#if leftover} |
||||
|
<div>+{leftover} more</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
img { |
||||
|
height: 32px; |
||||
|
max-width: 64px; |
||||
|
} |
||||
|
.file { |
||||
|
height: 32px; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: flex-start; |
||||
|
align-items: center; |
||||
|
padding: 0 8px; |
||||
|
color: var(--spectrum-global-color-gray-800); |
||||
|
border: 1px solid var(--spectrum-global-color-gray-300); |
||||
|
border-radius: 2px; |
||||
|
text-transform: uppercase; |
||||
|
font-weight: 500; |
||||
|
font-size: 11px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/checkbox/dist/index-vars.css" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<label |
||||
|
class="spectrum-Checkbox spectrum-Checkbox--sizeM spectrum-Checkbox--emphasized"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
class="spectrum-Checkbox-input" |
||||
|
id="checkbox-1" |
||||
|
disabled |
||||
|
checked={!!value} /> |
||||
|
<span class="spectrum-Checkbox-box"> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Checkbox-checkmark" |
||||
|
focusable="false" |
||||
|
aria-hidden="true"> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Dash100 spectrum-Checkbox-partialCheckmark" |
||||
|
focusable="false" |
||||
|
aria-hidden="true"> |
||||
|
<use xlink:href="#spectrum-css-icon-Dash100" /> |
||||
|
</svg> |
||||
|
</span> |
||||
|
</label> |
||||
|
|
||||
|
<style> |
||||
|
.spectrum-Checkbox { |
||||
|
min-height: 0; |
||||
|
} |
||||
|
.spectrum-Checkbox-box { |
||||
|
margin: 0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,27 @@ |
|||||
|
<script> |
||||
|
import StringRenderer from "./StringRenderer.svelte" |
||||
|
import BooleanRenderer from "./BooleanRenderer.svelte" |
||||
|
import DateTimeRenderer from "./DateTimeRenderer.svelte" |
||||
|
import RelationshipRenderer from "./RelationshipRenderer.svelte" |
||||
|
import AttachmentRenderer from "./AttachmentRenderer.svelte" |
||||
|
|
||||
|
export let schema |
||||
|
export let value |
||||
|
|
||||
|
const plainTypes = ["string", "options", "number", "longform"] |
||||
|
$: type = schema?.type ?? "string" |
||||
|
</script> |
||||
|
|
||||
|
{#if value != null && value !== ''} |
||||
|
{#if plainTypes.includes(type)} |
||||
|
<StringRenderer {value} /> |
||||
|
{:else if type === 'boolean'} |
||||
|
<BooleanRenderer {value} /> |
||||
|
{:else if type === 'datetime'} |
||||
|
<DateTimeRenderer {value} /> |
||||
|
{:else if type === 'link'} |
||||
|
<RelationshipRenderer {value} /> |
||||
|
{:else if type === 'attachment'} |
||||
|
<AttachmentRenderer {value} /> |
||||
|
{/if} |
||||
|
{/if} |
||||
@ -0,0 +1,13 @@ |
|||||
|
<script> |
||||
|
import dayjs from "dayjs" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div>{dayjs(value).format('MMMM D YYYY, HH:mm')}</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
width: 200px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,20 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/label/dist/index-vars.css" |
||||
|
|
||||
|
export let value |
||||
|
|
||||
|
const displayLimit = 5 |
||||
|
$: relationships = value?.slice(0, displayLimit) ?? [] |
||||
|
$: leftover = (value?.length ?? 0) - relationships.length |
||||
|
</script> |
||||
|
|
||||
|
{#each relationships as relationship} |
||||
|
{#if relationship?.primaryDisplay} |
||||
|
<span class="spectrum-Label spectrum-Label--grey"> |
||||
|
{relationship.primaryDisplay} |
||||
|
</span> |
||||
|
{/if} |
||||
|
{/each} |
||||
|
{#if leftover} |
||||
|
<div>+{leftover} more</div> |
||||
|
{/if} |
||||
@ -0,0 +1,13 @@ |
|||||
|
<script> |
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div>{value}</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
width: 150px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,290 @@ |
|||||
|
<script> |
||||
|
import { fade } from "svelte/transition" |
||||
|
import "@spectrum-css/table/dist/index-vars.css" |
||||
|
import { getContext } from "svelte" |
||||
|
import CellRenderer from "./CellRenderer.svelte" |
||||
|
|
||||
|
export let theme |
||||
|
export let size |
||||
|
export let dataProvider |
||||
|
export let columns |
||||
|
export let showAutoColumns |
||||
|
export let rowCount |
||||
|
export let quiet |
||||
|
|
||||
|
const component = getContext("component") |
||||
|
const { styleable, Provider } = getContext("sdk") |
||||
|
|
||||
|
// Config |
||||
|
const rowHeight = 55 |
||||
|
const headerHeight = 36 |
||||
|
const rowPreload = 5 |
||||
|
const maxRows = 100 |
||||
|
|
||||
|
// Sorting state |
||||
|
let sortColumn |
||||
|
let sortOrder |
||||
|
|
||||
|
// Table state |
||||
|
$: loaded = dataProvider?.loaded ?? false |
||||
|
$: rows = dataProvider?.rows ?? [] |
||||
|
$: visibleRowCount = loaded |
||||
|
? Math.min(rows.length, rowCount || maxRows, maxRows) |
||||
|
: Math.min(8, rowCount || maxRows) |
||||
|
$: scroll = rows.length > visibleRowCount |
||||
|
$: contentStyle = getContentStyle(visibleRowCount, scroll || !loaded) |
||||
|
$: sortedRows = sortRows(rows, sortColumn, sortOrder) |
||||
|
$: schema = dataProvider?.schema ?? {} |
||||
|
$: fields = getFields(schema, columns, showAutoColumns) |
||||
|
|
||||
|
// Scrolling state |
||||
|
let timeout |
||||
|
let nextScrollTop = 0 |
||||
|
let scrollTop = 0 |
||||
|
$: firstVisibleRow = calculateFirstVisibleRow(scrollTop) |
||||
|
$: lastVisibleRow = calculateLastVisibleRow( |
||||
|
firstVisibleRow, |
||||
|
visibleRowCount, |
||||
|
rows.length |
||||
|
) |
||||
|
|
||||
|
const getContentStyle = (visibleRows, useFixedHeight) => { |
||||
|
if (!useFixedHeight) { |
||||
|
return "" |
||||
|
} |
||||
|
return `height: ${headerHeight - 1 + visibleRows * (rowHeight + 1)}px;` |
||||
|
} |
||||
|
|
||||
|
const sortRows = (rows, sortColumn, sortOrder) => { |
||||
|
if (!sortColumn || !sortOrder) { |
||||
|
return rows |
||||
|
} |
||||
|
return rows.slice().sort((a, b) => { |
||||
|
const colA = a[sortColumn] |
||||
|
const colB = b[sortColumn] |
||||
|
if (sortOrder === "Descending") { |
||||
|
return colA > colB ? -1 : 1 |
||||
|
} else { |
||||
|
return colA > colB ? 1 : -1 |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const sortBy = field => { |
||||
|
if (field === sortColumn) { |
||||
|
sortOrder = sortOrder === "Descending" ? "Ascending" : "Descending" |
||||
|
} else { |
||||
|
sortColumn = field |
||||
|
sortOrder = "Descending" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const getFields = (schema, customColumns, showAutoColumns) => { |
||||
|
// Check for an invalid column selection |
||||
|
let invalid = false |
||||
|
customColumns?.forEach(column => { |
||||
|
if (schema[column] == null) { |
||||
|
invalid = true |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// Use column selection if it exists |
||||
|
if (!invalid && customColumns?.length) { |
||||
|
return customColumns |
||||
|
} |
||||
|
|
||||
|
// Otherwise generate columns |
||||
|
let columns = [] |
||||
|
let autoColumns = [] |
||||
|
Object.entries(schema).forEach(([field, fieldSchema]) => { |
||||
|
if (!fieldSchema?.autocolumn) { |
||||
|
columns.push(field) |
||||
|
} else if (showAutoColumns) { |
||||
|
autoColumns.push(field) |
||||
|
} |
||||
|
}) |
||||
|
return columns.concat(autoColumns) |
||||
|
} |
||||
|
|
||||
|
const onScroll = event => { |
||||
|
nextScrollTop = event.target.scrollTop |
||||
|
if (timeout) { |
||||
|
return |
||||
|
} |
||||
|
timeout = setTimeout(() => { |
||||
|
scrollTop = nextScrollTop |
||||
|
timeout = null |
||||
|
}, 50) |
||||
|
} |
||||
|
|
||||
|
const calculateFirstVisibleRow = scrollTop => { |
||||
|
return Math.max(Math.floor(scrollTop / (rowHeight + 1)) - rowPreload, 0) |
||||
|
} |
||||
|
|
||||
|
const calculateLastVisibleRow = (firstRow, visibleRowCount, allRowCount) => { |
||||
|
return Math.min(firstRow + visibleRowCount + 2 * rowPreload, allRowCount) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
{#if !loaded} |
||||
|
<div class="content" style={contentStyle} /> |
||||
|
{:else} |
||||
|
<div use:styleable={$component.styles}> |
||||
|
<div |
||||
|
on:scroll={onScroll} |
||||
|
lang="en" |
||||
|
dir="ltr" |
||||
|
class:quiet |
||||
|
style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`} |
||||
|
class={`spectrum ${size || 'spectrum--medium'} ${theme || 'spectrum--light'}`}> |
||||
|
<div class="content" style={contentStyle}> |
||||
|
<table class="spectrum-Table" class:spectrum-Table--quiet={quiet}> |
||||
|
<thead class="spectrum-Table-head"> |
||||
|
<tr> |
||||
|
{#if $component.children} |
||||
|
<th class="spectrum-Table-headCell"> |
||||
|
<div class="spectrum-Table-headCell-content" /> |
||||
|
</th> |
||||
|
{/if} |
||||
|
{#each fields as field} |
||||
|
<th |
||||
|
class="spectrum-Table-headCell is-sortable" |
||||
|
class:is-sorted-desc={sortColumn === field && sortOrder === 'Descending'} |
||||
|
class:is-sorted-asc={sortColumn === field && sortOrder === 'Ascending'} |
||||
|
on:click={() => sortBy(field)}> |
||||
|
<div class="spectrum-Table-headCell-content"> |
||||
|
<div class="title">{schema[field]?.name}</div> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-ArrowDown100 spectrum-Table-sortedIcon" |
||||
|
class:visible={sortColumn === field} |
||||
|
focusable="false" |
||||
|
aria-hidden="true"> |
||||
|
<use xlink:href="#spectrum-css-icon-Arrow100" /> |
||||
|
</svg> |
||||
|
</div> |
||||
|
</th> |
||||
|
{/each} |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody class="spectrum-Table-body"> |
||||
|
{#each sortedRows as row, idx} |
||||
|
<tr |
||||
|
class="spectrum-Table-row" |
||||
|
class:hidden={idx < firstVisibleRow || idx > lastVisibleRow}> |
||||
|
{#if idx >= firstVisibleRow && idx <= lastVisibleRow} |
||||
|
{#if $component.children} |
||||
|
<td |
||||
|
class="spectrum-Table-cell spectrum-Table-cell--divider"> |
||||
|
<div class="spectrum-Table-cell-content"> |
||||
|
<Provider data={row}> |
||||
|
<slot /> |
||||
|
</Provider> |
||||
|
</div> |
||||
|
</td> |
||||
|
{/if} |
||||
|
{#each fields as field} |
||||
|
<td class="spectrum-Table-cell"> |
||||
|
<div class="spectrum-Table-cell-content"> |
||||
|
<CellRenderer |
||||
|
schema={schema[field]} |
||||
|
value={row[field]} /> |
||||
|
</div> |
||||
|
</td> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
</tr> |
||||
|
{/each} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
.spectrum { |
||||
|
position: relative; |
||||
|
overflow: auto; |
||||
|
border: 1px solid |
||||
|
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important; |
||||
|
} |
||||
|
.spectrum.quiet { |
||||
|
border: none !important; |
||||
|
} |
||||
|
table { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Table-sortedIcon { |
||||
|
opacity: 0; |
||||
|
display: block !important; |
||||
|
} |
||||
|
.spectrum-Table-sortedIcon.visible { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
.spectrum, |
||||
|
th { |
||||
|
border-bottom: 1px solid |
||||
|
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important; |
||||
|
} |
||||
|
th { |
||||
|
vertical-align: middle; |
||||
|
height: var(--header-height); |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
background-color: var(--spectrum-global-color-gray-100); |
||||
|
z-index: 2; |
||||
|
} |
||||
|
.spectrum-Table-headCell-content { |
||||
|
white-space: nowrap; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: flex-start; |
||||
|
align-items: center; |
||||
|
user-select: none; |
||||
|
} |
||||
|
.spectrum-Table-headCell-content .title { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
} |
||||
|
|
||||
|
tbody { |
||||
|
z-index: 1; |
||||
|
} |
||||
|
tbody tr { |
||||
|
height: var(--row-height); |
||||
|
} |
||||
|
tbody tr.hidden { |
||||
|
height: calc(var(--row-height) + 1px); |
||||
|
} |
||||
|
tbody tr.offset { |
||||
|
background-color: red; |
||||
|
display: block; |
||||
|
} |
||||
|
td { |
||||
|
padding-top: 0; |
||||
|
padding-bottom: 0; |
||||
|
border-bottom: none !important; |
||||
|
border-left: none !important; |
||||
|
border-right: none !important; |
||||
|
border-top: 1px solid |
||||
|
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important; |
||||
|
} |
||||
|
tr:first-child td { |
||||
|
border-top: none !important; |
||||
|
} |
||||
|
.spectrum:not(.quiet) td.spectrum-Table-cell--divider { |
||||
|
width: 1px; |
||||
|
border-right: 1px solid |
||||
|
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important; |
||||
|
} |
||||
|
.spectrum-Table-cell-content { |
||||
|
height: var(--row-height); |
||||
|
white-space: nowrap; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: flex-start; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default as table } from "./Table.svelte" |
||||
Loading…
Reference in new issue