Grid UI in data section (#10329)
* Add WIP spreadsheet * Add footer and improve styles * Refactor to use IDs and support changing text values inline * Add inline editing of options fields * Add row deletion and fix sizing * Add ability to add new rows * Fix z-index issue with option cells * Remove deletion notification and fix selection * Add gap between items in an options cell * Tweak options cell to be pixel perfect * Fix padding around sheet not working when scrolling * Add resizable columns and add support for all themes * Allow multiselect component and field to support text values * Generate inclusion schema when importing multiselect columns * Add support for multiselect type * Add number cell * Add functional date cell * Disable editing autocolumns * Make sticky column the primary display and fix opening options cells * Improve display of relationship cell and options cell * Support empty dates and use CSS variables for easier styling * Use more CSS variables and add utils to spreadsheets * Add drag and drop column reordering * Break out reordering logic into new stores * Rename reordering to reorder * Break out other components from spreadsheet for cleaner code * Break out spreadsheet body into its own component * Split into more modular components and try virtual rendering * Test absolute positioning * Optimise virtual rendering for both columns and rows to handle infinitely large datasets * Optimise scrolling and virtual rendering performance * Fix columnn reordering * Migrate sheet to data section, improve reordering and reszing * Clean up more sheet state and increase performance * Fix multiple issues with z-index, reordering and resizing * Fix date cells in sheets * Separate data fetching logic from main sheet and tidy up * Add infinite scroll, improve row fetching, add error handling, fix svelte store updates * Fix overly thin scrollbars in firefox * Use nicer checkboxes and fix some hover styles * Fix issue reordering columns in firefox and increase performance * Tidy up * Use search endpoint instead of get endpoint to fetch individual rows so that relationship enrichment occurs * Tidy up * Fix relationship issues when creating rows * Optimise resetting data to smoothly transition when changing datasource * Add WIP virtual dom implementation to massively increase performance * Refactor spreadsheet into more discreet components * Fix multiple issues, clean up rendering, improve performance * Tune cell sizes * Fix some scroll issues and add shadow to sticky column * Fix issue when no primary display is set * Add padding to sheet * Improve styles * Allow reordering columns to be the first column after sticky column * Fix row hover state not being removed * Update hovered row on wheel * Update scroll styles and z-index * Improve scroll logic and handle horizontal wheel events * Simplify and improve z index styles * Fix styles when using no sticky columns * Improve rendering performance * Improve performance by removing keyed each blocks and fix reorder target styling * Ensure scroll top is always properly reset and add config store * Allow configuring selecting rows and adding rows * Integrate sheet into data section better * Add back in functional delete row button * Refactor stores and make state more modular * Lint and remove unused deps * Remove add column button * Fix options cells being unable to scroll * Add WIP initial multi-user websocket implementation for sheets * Add WIP multi-user UI for sheet interface * Fix issues with not disconnecting users when swapping datasource and improve multi-user UI * Update layout and remove logging * WIP column popovers for dataspace sheets * Add popovers to sheet column headers, improve mouse UX * Tidy reordering stuff * Refactor resizing logic into store and improve UX around hover events when resizing/reordering * Add column sorting and reordering via popover * Handle context menu events in header cells * Fully integrates sheets with datasection and remove lots of old stuff * Fix buttons being highlighted when filters are set * Add flags for controlling editing and adding rows in sheets * Count context menu clicks when considering the click outside handler * Prevent adding rows to users table and remove log * Expose loading state of sheet and improve column highlighting logic * Small style updates * Update delete button and allow horizontal scrolling * Add context menu to sheets with deletion and duplication features * Improve UX around selecting rows and cells * Add basic keyboard interactions to dataspaces * Improve keyboard navigation in sheets * Remove unnecessary searching through large rows array * Fix issue with deleting rows and fix relationship cells displaying undefined * Improve loading state * Update menu width * Merge with new shared-core and moved lucene utils * Improve rendering performance and simplify component props * Remove new row component and improve mouse interactions * Tidy up buttons above sheets and add FAB for adding rows * Optimise sheet data loading and add sort button * Update sorting and remove logs * Add sheet button to control column visibilty, improve sorting, improve disabled states * Fix bug with select placeholders and fix sorting loops causing endless refreshes * Update filter button to look consistent and add double click to resize columns to default width * Ensure all derived stores have default values * Reset scrolling when datasource changes and fix wasted pagination calls * Improve performance by removing searches through the full row array * Add advanced key handling for spreadsheets and improve blur and focus UX * Ensure the selected cell is always visible * Add icons for all data types * Add new long form text cell * Add boolean cell * Add ability to focus first cell via tab * Add cells for formulae and JSON * Remove console logs * Add attachment cell * Increase padding to account for attachment dropdown * Prevent deleting autocolumns via keyboard * Fix attachments overflowing * Improve sort button, remove header more icons unless hovered and highlight sorted column * Add functional relationship cell * Improve relationship cell * Fix race conditions and edge cases in relationship cell * Update user avatar colours * Improve preservation of column widths in sheets when making schema changes * Remove redundant sheet schema context and fix issues with mutating table schema * Disable websocket in sheets * Rollback state changes when row saving fails * Fix one-to-many relationships allowing selecting multiple rows on both sides * Remove log * Make sheet gutter width customisable * Allow expanding rows using existing edit row modal * Fix text cell not using full width * Sort columns to put autocolumns last * Add new footer for adding rows, improve store memoization, support inverting all data types * Improve animations for adding rows and handle add row failure * Ensure all sheet feature flags work as expected and fix multi row deletion * Fix options ordering * Fix add row button not appearing when horizontal scrollbar is hidden * Fix selecting newly created rows * Remove log and add notification when creating or editing columns * Move new row component to top, automatically invert cell renderers when required * Add resizable rows * Fix overlapping long form text borders * Fix scroll not working in new row * Update new row component, fix z-index issues, improve UX * Large refactors to row creation, naming and sheet APIs * Refactor stores to fix dependency issues, use modals for adding rows, simplify sheet * Fix resize overlays * Add custom colors for drop shadows and blue-100 to all themes, fix sticky column shadow * Increase horizontal padding when scrolling to a selected cell * Add multiple validation improvements * Add validation to duplicating rows * Remove log * Restore missing event handler * Improve data fetch reset logic, fix issues with stale cache in spreadsheets * Fix issue with cell colors, improve row API interactions to avoid relationship issues due to API response differences * Fix filters not working * Simplify logic for reordering and add new overlay. Simplify sheet cells * Fix importing and exporting with sheets * Fix reorder overlay z-index issue * Fix issue when no display column exists * Fix issue with display column not being able to be unset * Add persitence to column size and order in sheets * Improve sheet integration with data section and add horizontal cell inversion * Fix double click resizing of sticky column * Make column visibility persistent and refactor column updating * Improve sheet loading states * Add beta button to sheet, tidy up constants * Work around table API inconsistencies to handle table schema updates * Add additional reorder options and improve beta button * Improve sorting * Add copy and paste to spreadsheet and add immediate editing of cells without additional click * Remove copy/paste rows, remove move to start/end, improve copy/paste for cell values * Fix dependency ordering * Refactor other sheet stores to improve dependency ordering * Fix errors not showing in sticky column and clear cell value on backspace press * Rewrite relationship cell and update default column widths * Ensure dynamic row height is properly accounted for * Update text cells, number cells, long form field cells and relationship cells to respect row height * Fix row heights with sticky column * Update JSON, boolean and date cells to respect row height * Update attachment cell to respect row height * Use unique background for focused cell * Standardise shadows across cell types * Persist row height as table metadata * Improve a few design issues * Clean up * Fix relationship cells not being readonly * Lint * Fix icon padding in relationship picker * Improve styles in relationship dropdown * Update shadow * Update relationship icons * Update relationship icons * Update error label max size and position * Prevent using invalid data types as display columns * Add menu option to edit rows in modal * Prevent sheet handling key events sourcing from modals * Standardise menu overlay shadow and add count to relationship cells when hovering * Improve relationship cell performance * Remove spellcheck from text fields * Fix resize overlay handler height * Fix reorder overlay height * Remove unused code and change selected table faster in data section * Fix table selection not working when on datasource page * Improve sheet loading state * Add rowHeight property to table types * Restore builder middleware * Remove any naming of dataspaces * Lint * Disable row import button for users table and add optional chaining to spreadsheetsocket invocations to fix tests * Use unique user edit modal for editing users in sheets * Add schemaOverrides prop to sheet and use it to customise user table schema * Update number icon * Fix primary display column not properly disabling certain menu options * Merge * Update beta button position slightly * Update beta button text * Fix HMR for custom plugins which was broken due to signed minio links * Add maze link to grid * Update koa <> socket.io integation to improve fake koa context and allow current app middleware * Rename sheet to grid * Fix menu postiion, fix copy and paste in menu not working * Remove commented out usages of websocket emissions for grid
This commit is contained in:
parent
2007d23f67
commit
55ce83c444
|
@ -84,7 +84,7 @@
|
|||
"@spectrum-css/vars": "3.0.1",
|
||||
"dayjs": "^1.10.4",
|
||||
"easymde": "^2.16.1",
|
||||
"svelte-flatpickr": "^3.2.3",
|
||||
"svelte-flatpickr": "^3.3.2",
|
||||
"svelte-portal": "^1.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
{/if}
|
||||
{#if icon}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-Icon--size{size}"
|
||||
class="spectrum-Icon spectrum-Icon--sizeS"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
aria-label={icon}
|
||||
|
|
|
@ -6,6 +6,9 @@ let clickHandlers = []
|
|||
*/
|
||||
const handleClick = event => {
|
||||
// Ignore click if this is an ignored class
|
||||
if (event.target.closest('[data-ignore-click-outside="true"]')) {
|
||||
return
|
||||
}
|
||||
for (let className of ignoredClasses) {
|
||||
if (event.target.closest(className)) {
|
||||
return
|
||||
|
@ -29,6 +32,7 @@ const handleClick = event => {
|
|||
})
|
||||
}
|
||||
document.documentElement.addEventListener("click", handleClick, true)
|
||||
document.documentElement.addEventListener("contextmenu", handleClick, true)
|
||||
|
||||
/**
|
||||
* Adds or updates a click handler
|
||||
|
|
|
@ -138,7 +138,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="container" class:compact>
|
||||
{#if selectedImage}
|
||||
{#if gallery}
|
||||
<div class="gallery">
|
||||
|
@ -355,6 +355,9 @@
|
|||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
.compact .spectrum-Dropzone {
|
||||
padding: 6px 0 !important;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
|
@ -379,6 +382,17 @@
|
|||
object-fit: contain;
|
||||
margin: 20px 30px;
|
||||
}
|
||||
.compact .placeholder,
|
||||
.compact img {
|
||||
margin: 10px 16px;
|
||||
}
|
||||
.compact img {
|
||||
height: 90px;
|
||||
}
|
||||
.compact .gallery {
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -447,6 +461,13 @@
|
|||
.disabled .spectrum-Heading--sizeL {
|
||||
color: var(--spectrum-alias-text-color-disabled);
|
||||
}
|
||||
.compact .spectrum-Dropzone {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.compact .spectrum-IllustratedMessage-description {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-top: 20px;
|
||||
|
|
|
@ -20,12 +20,13 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: selectedLookupMap = getSelectedLookupMap(value)
|
||||
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
|
||||
$: selectedLookupMap = getSelectedLookupMap(arrayValue)
|
||||
$: optionLookupMap = getOptionLookupMap(options)
|
||||
|
||||
$: fieldText = getFieldText(value, optionLookupMap, placeholder)
|
||||
$: fieldText = getFieldText(arrayValue, optionLookupMap, placeholder)
|
||||
$: isOptionSelected = optionValue => selectedLookupMap[optionValue] === true
|
||||
$: toggleOption = makeToggleOption(selectedLookupMap, value)
|
||||
$: toggleOption = makeToggleOption(selectedLookupMap, arrayValue)
|
||||
|
||||
const getFieldText = (value, map, placeholder) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
|
@ -84,7 +85,7 @@
|
|||
{readonly}
|
||||
{fieldText}
|
||||
{options}
|
||||
isPlaceholder={!value?.length}
|
||||
isPlaceholder={!arrayValue.length}
|
||||
{autocomplete}
|
||||
bind:fetchTerm
|
||||
{useFetch}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
export let gallery = true
|
||||
export let fileTags = []
|
||||
export let maximum = undefined
|
||||
export let compact = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
|
@ -37,6 +38,7 @@
|
|||
{gallery}
|
||||
{fileTags}
|
||||
{maximum}
|
||||
{compact}
|
||||
on:change={onChange}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
export let dismissible = true
|
||||
export let offset = 5
|
||||
export let customHeight
|
||||
export let animate = true
|
||||
|
||||
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
|
||||
|
||||
|
@ -78,7 +79,7 @@
|
|||
class="spectrum-Popover is-open"
|
||||
role="presentation"
|
||||
style="height: {customHeight}"
|
||||
transition:fly|local={{ y: -20, duration: 200 }}
|
||||
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
|
||||
const displayLimit = 5
|
||||
|
||||
$: badges = Array.isArray(value) ? value.slice(0, displayLimit) : []
|
||||
$: leftover = (value?.length ?? 0) - badges.length
|
||||
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
|
||||
$: badges = arrayValue.slice(0, displayLimit)
|
||||
$: leftover = arrayValue.length - badges.length
|
||||
</script>
|
||||
|
||||
{#each badges as badge}
|
||||
|
|
|
@ -143,7 +143,7 @@
|
|||
}
|
||||
fields?.forEach(field => {
|
||||
const fieldSchema = schema[field]
|
||||
if (fieldSchema.width) {
|
||||
if (fieldSchema.width && typeof fieldSchema.width === "string") {
|
||||
style += ` ${fieldSchema.width}`
|
||||
} else {
|
||||
style += " minmax(auto, 1fr)"
|
||||
|
|
|
@ -97,4 +97,22 @@
|
|||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom theme additions */
|
||||
.spectrum--darkest {
|
||||
--drop-shadow: rgba(0, 0, 0, 0.6);
|
||||
--spectrum-global-color-blue-100: rgb(28, 33, 43);
|
||||
}
|
||||
.spectrum--dark {
|
||||
--drop-shadow: rgba(0, 0, 0, 0.3);
|
||||
--spectrum-global-color-blue-100: rgb(42, 47, 57);
|
||||
}
|
||||
.spectrum--light {
|
||||
--drop-shadow: rgba(0, 0, 0, 0.075);
|
||||
--spectrum-global-color-blue-100: rgb(240, 245, 255);
|
||||
}
|
||||
.spectrum--lightest {
|
||||
--drop-shadow: rgba(0, 0, 0, 0.05);
|
||||
--spectrum-global-color-blue-100: rgb(240, 244, 255);
|
||||
}
|
||||
|
|
|
@ -1,286 +1,74 @@
|
|||
<script>
|
||||
import { fade } from "svelte/transition"
|
||||
import { tables } from "stores/backend"
|
||||
import CreateRowButton from "./buttons/CreateRowButton.svelte"
|
||||
import CreateColumnButton from "./buttons/CreateColumnButton.svelte"
|
||||
import CreateViewButton from "./buttons/CreateViewButton.svelte"
|
||||
import ExistingRelationshipButton from "./buttons/ExistingRelationshipButton.svelte"
|
||||
import ExportButton from "./buttons/ExportButton.svelte"
|
||||
import ImportButton from "./buttons/ImportButton.svelte"
|
||||
import EditRolesButton from "./buttons/EditRolesButton.svelte"
|
||||
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
|
||||
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
|
||||
import TableFilterButton from "./buttons/TableFilterButton.svelte"
|
||||
import Table from "./Table.svelte"
|
||||
import { TableNames } from "constants"
|
||||
import CreateEditRow from "./modals/CreateEditRow.svelte"
|
||||
import {
|
||||
Pagination,
|
||||
Heading,
|
||||
Body,
|
||||
Layout,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { Grid } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import GridAddColumnModal from "components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte"
|
||||
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
|
||||
import GridEditUserModal from "components/backend/DataTable/modals/grid/GridEditUserModal.svelte"
|
||||
import GridCreateViewButton from "components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte"
|
||||
import GridImportButton from "components/backend/DataTable/buttons/grid/GridImportButton.svelte"
|
||||
import GridExportButton from "components/backend/DataTable/buttons/grid/GridExportButton.svelte"
|
||||
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
|
||||
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
|
||||
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
|
||||
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
|
||||
|
||||
let hideAutocolumns = true
|
||||
let filters
|
||||
const userSchemaOverrides = {
|
||||
firstName: { name: "First name", disabled: true },
|
||||
lastName: { name: "Last name", disabled: true },
|
||||
email: { name: "Email", disabled: true },
|
||||
roleId: { name: "Role", disabled: true },
|
||||
status: { name: "Status", disabled: true },
|
||||
}
|
||||
|
||||
$: isUsersTable = $tables.selected?._id === TableNames.USERS
|
||||
$: type = $tables.selected?.type
|
||||
$: isInternal = type !== "external"
|
||||
$: schema = $tables.selected?.schema
|
||||
$: enrichedSchema = enrichSchema($tables.selected?.schema)
|
||||
$: id = $tables.selected?._id
|
||||
$: fetch = createFetch(id)
|
||||
$: hasCols = checkHasCols(schema)
|
||||
$: hasRows = !!$fetch.rows?.length
|
||||
$: showError($fetch.error)
|
||||
$: id, (filters = null)
|
||||
|
||||
let appliedFilter
|
||||
let rawFilter
|
||||
let appliedSort
|
||||
let selectedRows = []
|
||||
|
||||
$: enrichedSchema,
|
||||
() => {
|
||||
appliedFilter = null
|
||||
rawFilter = null
|
||||
appliedSort = null
|
||||
selectedRows = []
|
||||
}
|
||||
|
||||
$: if (Number.isInteger($fetch.pageNumber)) {
|
||||
selectedRows = []
|
||||
}
|
||||
|
||||
const showError = error => {
|
||||
if (error) {
|
||||
notifications.error(error?.message || "Unable to fetch data.")
|
||||
}
|
||||
}
|
||||
|
||||
const enrichSchema = schema => {
|
||||
let tempSchema = { ...schema }
|
||||
tempSchema._id = {
|
||||
type: "internal",
|
||||
editable: false,
|
||||
displayName: "ID",
|
||||
autocolumn: true,
|
||||
}
|
||||
if (isInternal) {
|
||||
tempSchema._rev = {
|
||||
type: "internal",
|
||||
editable: false,
|
||||
displayName: "Revision",
|
||||
autocolumn: true,
|
||||
}
|
||||
}
|
||||
|
||||
return tempSchema
|
||||
}
|
||||
|
||||
const checkHasCols = schema => {
|
||||
if (!schema || Object.keys(schema).length === 0) {
|
||||
return false
|
||||
}
|
||||
let fields = Object.values(schema)
|
||||
for (let field of fields) {
|
||||
if (!field.autocolumn) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetches new data whenever the table changes
|
||||
const createFetch = tableId => {
|
||||
return fetchData({
|
||||
API,
|
||||
datasource: {
|
||||
tableId,
|
||||
type: "table",
|
||||
},
|
||||
options: {
|
||||
schema,
|
||||
limit: 10,
|
||||
paginate: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch data whenever sorting option changes
|
||||
const onSort = async e => {
|
||||
const sort = {
|
||||
sortColumn: e.detail.column,
|
||||
sortOrder: e.detail.order,
|
||||
}
|
||||
await fetch.update(sort)
|
||||
appliedSort = { ...sort }
|
||||
appliedSort.sortOrder = appliedSort.sortOrder.toLowerCase()
|
||||
selectedRows = []
|
||||
}
|
||||
|
||||
// Fetch data whenever filters change
|
||||
const onFilter = e => {
|
||||
filters = e.detail
|
||||
fetch.update({
|
||||
filter: filters,
|
||||
})
|
||||
appliedFilter = e.detail
|
||||
}
|
||||
|
||||
// Fetch data whenever schema changes
|
||||
const onUpdateColumns = () => {
|
||||
selectedRows = []
|
||||
fetch.refresh()
|
||||
tables.fetchTable(id)
|
||||
}
|
||||
|
||||
// Fetch data whenever rows are modified. Unfortunately we have to lose
|
||||
// our pagination place, as our bookmarks will have shifted.
|
||||
const onUpdateRows = () => {
|
||||
selectedRows = []
|
||||
fetch.refresh()
|
||||
}
|
||||
|
||||
// When importing new rows it is better to reinitialise request/paging data.
|
||||
// Not doing so causes inconsistency in paging behaviour and content.
|
||||
const onImportData = () => {
|
||||
fetch.getInitialData()
|
||||
}
|
||||
$: isUsersTable = id === TableNames.USERS
|
||||
$: isInternal = $tables.selected?.type !== "external"
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Table
|
||||
title={$tables.selected?.name}
|
||||
schema={enrichedSchema}
|
||||
{type}
|
||||
<div class="wrapper">
|
||||
<Grid
|
||||
{API}
|
||||
tableId={id}
|
||||
data={$fetch.rows}
|
||||
bind:hideAutocolumns
|
||||
loading={!$fetch.loaded}
|
||||
on:sort={onSort}
|
||||
allowEditing
|
||||
disableSorting
|
||||
on:updatecolumns={onUpdateColumns}
|
||||
on:updaterows={onUpdateRows}
|
||||
on:selectionUpdated={e => {
|
||||
selectedRows = e.detail
|
||||
}}
|
||||
customPlaceholder
|
||||
allowAddRows={!isUsersTable}
|
||||
allowDeleteRows={!isUsersTable}
|
||||
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
|
||||
on:updatetable={e => tables.updateTable(e.detail)}
|
||||
>
|
||||
<div class="buttons">
|
||||
<div class="left-buttons">
|
||||
<CreateColumnButton
|
||||
highlighted={$fetch.loaded && (!hasCols || !hasRows)}
|
||||
on:updatecolumns={onUpdateColumns}
|
||||
/>
|
||||
{#if !isUsersTable}
|
||||
<CreateRowButton
|
||||
on:updaterows={onUpdateRows}
|
||||
title={"Create row"}
|
||||
modalContentComponent={CreateEditRow}
|
||||
disabled={!hasCols}
|
||||
highlighted={$fetch.loaded && hasCols && !hasRows}
|
||||
/>
|
||||
{/if}
|
||||
{#if isInternal}
|
||||
<CreateViewButton disabled={!hasCols || !hasRows} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="right-buttons">
|
||||
<ManageAccessButton resourceId={$tables.selected?._id} />
|
||||
{#if isUsersTable}
|
||||
<EditRolesButton />
|
||||
{/if}
|
||||
{#if !isInternal}
|
||||
<ExistingRelationshipButton
|
||||
table={$tables.selected}
|
||||
on:updatecolumns={onUpdateColumns}
|
||||
/>
|
||||
{/if}
|
||||
<HideAutocolumnButton bind:hideAutocolumns />
|
||||
<ImportButton
|
||||
disabled={$tables.selected?._id === "ta_users"}
|
||||
tableId={$tables.selected?._id}
|
||||
on:importrows={onImportData}
|
||||
/>
|
||||
<ExportButton
|
||||
disabled={!hasRows || !hasCols}
|
||||
view={$tables.selected?._id}
|
||||
filters={appliedFilter}
|
||||
sorting={appliedSort}
|
||||
{selectedRows}
|
||||
/>
|
||||
{#key id}
|
||||
<TableFilterButton
|
||||
{schema}
|
||||
{filters}
|
||||
on:change={onFilter}
|
||||
disabled={!hasCols}
|
||||
tableId={id}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
<div slot="placeholder">
|
||||
<Layout gap="S">
|
||||
{#if !hasCols}
|
||||
<Heading>Let's create some columns</Heading>
|
||||
<Body>
|
||||
Start building out your table structure<br />
|
||||
by adding some columns
|
||||
</Body>
|
||||
{:else}
|
||||
<Heading>Now let's add a row</Heading>
|
||||
<Body>
|
||||
Add some data to your table<br />
|
||||
by adding some rows
|
||||
</Body>
|
||||
{/if}
|
||||
</Layout>
|
||||
</div>
|
||||
</Table>
|
||||
{#key id}
|
||||
<div in:fade={{ delay: 200, duration: 100 }}>
|
||||
<div class="pagination">
|
||||
<Pagination
|
||||
page={$fetch.pageNumber + 1}
|
||||
hasPrevPage={$fetch.hasPrevPage}
|
||||
hasNextPage={$fetch.hasNextPage}
|
||||
goToPrevPage={$fetch.loading ? null : fetch.prevPage}
|
||||
goToNextPage={$fetch.loading ? null : fetch.nextPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
<svelte:fragment slot="controls">
|
||||
{#if isInternal}
|
||||
<GridCreateViewButton />
|
||||
{/if}
|
||||
<GridManageAccessButton />
|
||||
{#if isUsersTable}
|
||||
<EditRolesButton />
|
||||
{/if}
|
||||
{#if !isInternal}
|
||||
<GridRelationshipButton />
|
||||
{/if}
|
||||
<GridImportButton disabled={isUsersTable} />
|
||||
<GridExportButton />
|
||||
<GridFilterButton />
|
||||
<GridAddColumnModal />
|
||||
<GridEditColumnModal />
|
||||
{#if isUsersTable}
|
||||
<GridEditUserModal />
|
||||
{:else}
|
||||
<GridCreateEditRowModal />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
.buttons {
|
||||
.wrapper {
|
||||
flex: 1 1 auto;
|
||||
margin: -28px -40px -40px -40px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.left-buttons,
|
||||
.right-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
flex-direction: column;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
<script>
|
||||
import { fade } from "svelte/transition"
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { Table, Modal, Heading, notifications, Layout } from "@budibase/bbui"
|
||||
import { API } from "api"
|
||||
import { Table, Heading, Layout } from "@budibase/bbui"
|
||||
import Spinner from "components/common/Spinner.svelte"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import DeleteRowsButton from "./buttons/DeleteRowsButton.svelte"
|
||||
import CreateEditRow from "./modals/CreateEditRow.svelte"
|
||||
import CreateEditUser from "./modals/CreateEditUser.svelte"
|
||||
import CreateEditColumn from "./modals/CreateEditColumn.svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import {
|
||||
TableNames,
|
||||
UNEDITABLE_USER_FIELDS,
|
||||
|
@ -22,7 +17,6 @@
|
|||
export let data = []
|
||||
export let tableId
|
||||
export let title
|
||||
export let allowEditing = false
|
||||
export let loading = false
|
||||
export let hideAutocolumns
|
||||
export let rowCount
|
||||
|
@ -32,12 +26,7 @@
|
|||
const dispatch = createEventDispatcher()
|
||||
|
||||
let selectedRows = []
|
||||
let editableColumn
|
||||
let editableRow
|
||||
let editRowModal
|
||||
let editColumnModal
|
||||
let customRenderers = []
|
||||
let confirmDelete
|
||||
|
||||
$: selectedRows, dispatch("selectionUpdated", selectedRows)
|
||||
$: isUsersTable = tableId === TableNames.USERS
|
||||
|
@ -92,36 +81,6 @@
|
|||
`/builder/app/${$params.application}/data/table/${tableId}/relationship/${rowId}/${fieldName}`
|
||||
)
|
||||
}
|
||||
|
||||
const deleteRows = async targetRows => {
|
||||
try {
|
||||
await API.deleteRows({
|
||||
tableId,
|
||||
rows: targetRows,
|
||||
})
|
||||
|
||||
const deletedRowIds = targetRows.map(row => row._id)
|
||||
data = data.filter(row => deletedRowIds.indexOf(row._id))
|
||||
|
||||
notifications.success(`Successfully deleted ${targetRows.length} rows`)
|
||||
} catch (error) {
|
||||
notifications.error("Error deleting rows")
|
||||
}
|
||||
}
|
||||
|
||||
const editRow = row => {
|
||||
editableRow = row
|
||||
if (row) {
|
||||
editRowModal.show()
|
||||
}
|
||||
}
|
||||
|
||||
const editColumn = field => {
|
||||
editableColumn = cloneDeep(schema?.[field])
|
||||
if (editableColumn) {
|
||||
editColumnModal.show()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Layout noPadding gap="S">
|
||||
|
@ -138,16 +97,6 @@
|
|||
{/if}
|
||||
<div class="popovers">
|
||||
<slot />
|
||||
{#if !isUsersTable && selectedRows.length > 0}
|
||||
<DeleteRowsButton
|
||||
on:updaterows
|
||||
{selectedRows}
|
||||
deleteRows={async rows => {
|
||||
await deleteRows(rows)
|
||||
resetSelectedRows()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
{#key tableId}
|
||||
|
@ -160,13 +109,7 @@
|
|||
{rowCount}
|
||||
{disableSorting}
|
||||
{customPlaceholder}
|
||||
bind:selectedRows
|
||||
allowSelectRows={allowEditing && !isUsersTable}
|
||||
allowEditRows={allowEditing}
|
||||
allowEditColumns={allowEditing}
|
||||
showAutoColumns={!hideAutocolumns}
|
||||
on:editcolumn={e => editColumn(e.detail)}
|
||||
on:editrow={e => editRow(e.detail)}
|
||||
on:clickrelationship={e => selectRelationship(e.detail)}
|
||||
on:sort
|
||||
>
|
||||
|
@ -176,42 +119,6 @@
|
|||
{/key}
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={editRowModal}>
|
||||
<svelte:component
|
||||
this={editRowComponent}
|
||||
on:updaterows
|
||||
on:deleteRows={() => {
|
||||
confirmDelete.show()
|
||||
}}
|
||||
row={editableRow}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDelete}
|
||||
okText="Delete"
|
||||
onOk={async () => {
|
||||
if (editableRow) {
|
||||
await deleteRows([editableRow])
|
||||
}
|
||||
editableRow = undefined
|
||||
}}
|
||||
onCancel={async () => {
|
||||
editRow(editableRow)
|
||||
}}
|
||||
title="Confirm Deletion"
|
||||
>
|
||||
Are you sure you want to delete this row?
|
||||
</ConfirmDialog>
|
||||
|
||||
<Modal bind:this={editColumnModal}>
|
||||
<CreateEditColumn
|
||||
field={editableColumn}
|
||||
on:updatecolumns
|
||||
onClosed={editColumnModal.hide}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.table-title {
|
||||
height: 24px;
|
||||
|
|
|
@ -57,7 +57,6 @@
|
|||
{data}
|
||||
{loading}
|
||||
{type}
|
||||
allowEditing={false}
|
||||
rowCount={10}
|
||||
bind:hideAutocolumns
|
||||
>
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
<ActionButton
|
||||
icon="Calculator"
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
active={view.field && view.calculation}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<script>
|
||||
import { ActionButton, Modal } from "@budibase/bbui"
|
||||
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
|
||||
|
||||
export let highlighted = false
|
||||
export let disabled = false
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
{disabled}
|
||||
selected={highlighted}
|
||||
emphasized={highlighted}
|
||||
icon="TableColumnAddRight"
|
||||
quiet
|
||||
size="S"
|
||||
on:click={modal.show}
|
||||
>
|
||||
Create column
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<CreateEditColumn on:updatecolumns />
|
||||
</Modal>
|
|
@ -1,26 +0,0 @@
|
|||
<script>
|
||||
import { ActionButton, Modal } from "@budibase/bbui"
|
||||
import CreateEditRow from "../modals/CreateEditRow.svelte"
|
||||
|
||||
export let modalContentComponent = CreateEditRow
|
||||
export let title = "Create row"
|
||||
export let disabled = false
|
||||
export let highlighted = false
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
{disabled}
|
||||
emphasized={highlighted}
|
||||
selected={highlighted}
|
||||
icon="TableRowAddBottom"
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
>
|
||||
{title}
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<svelte:component this={modalContentComponent} on:updaterows />
|
||||
</Modal>
|
|
@ -1,21 +0,0 @@
|
|||
<script>
|
||||
import { Modal, ActionButton } from "@budibase/bbui"
|
||||
import CreateViewModal from "../modals/CreateViewModal.svelte"
|
||||
|
||||
export let disabled = false
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
{disabled}
|
||||
icon="CollectionAdd"
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
>
|
||||
Create view
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<CreateViewModal />
|
||||
</Modal>
|
|
@ -19,7 +19,7 @@
|
|||
$: text = `${item}${selectedRows?.length === 1 ? "" : "s"}`
|
||||
</script>
|
||||
|
||||
<Button icon="Delete" size="s" warning quiet on:click={modal.show}>
|
||||
<Button icon="Delete" warning quiet on:click={modal.show}>
|
||||
Delete
|
||||
{selectedRows.length}
|
||||
{text}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
<script>
|
||||
import { Button, Modal } from "@budibase/bbui"
|
||||
import { ActionButton, Modal } from "@budibase/bbui"
|
||||
import EditRolesModal from "../modals/EditRoles.svelte"
|
||||
|
||||
let modal
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Button icon="UsersLock" primary size="S" quiet on:click={modal.show}>
|
||||
Edit roles
|
||||
</Button>
|
||||
</div>
|
||||
<ActionButton icon="UsersLock" quiet on:click={modal.show}>
|
||||
Edit roles
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<EditRolesModal />
|
||||
</Modal>
|
||||
|
|
|
@ -7,15 +7,23 @@
|
|||
export let table
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: datasource = findDatasource(table?._id)
|
||||
$: plusTables = datasource?.plus
|
||||
? Object.values(datasource?.entities || {})
|
||||
: []
|
||||
$: datasource = $datasources.list.find(
|
||||
source => source._id === table?.sourceId
|
||||
)
|
||||
|
||||
let modal
|
||||
|
||||
const findDatasource = tableId => {
|
||||
return $datasources.list.find(datasource => {
|
||||
return (
|
||||
Object.values(datasource.entities || {}).find(entity => {
|
||||
return entity._id === tableId
|
||||
}) != null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function saveRelationship() {
|
||||
try {
|
||||
// Create datasource
|
||||
|
@ -28,15 +36,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if table.sourceId}
|
||||
{#if datasource}
|
||||
<div>
|
||||
<ActionButton
|
||||
icon="DataCorrelated"
|
||||
primary
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
>
|
||||
<ActionButton icon="DataCorrelated" primary quiet on:click={modal.show}>
|
||||
Define existing relationship
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
|
|
@ -11,13 +11,7 @@
|
|||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
{disabled}
|
||||
icon="DataDownload"
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
>
|
||||
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}>
|
||||
Export
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
<Button
|
||||
icon="Group"
|
||||
primary
|
||||
size="S"
|
||||
quiet
|
||||
active={!!view.groupBy}
|
||||
on:click={modal.show}
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
<ActionButton
|
||||
icon={hideAutocolumns ? "VisibilityOff" : "Visibility"}
|
||||
primary
|
||||
size="S"
|
||||
quiet
|
||||
on:click={hideOrUnhide}
|
||||
>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
let modal
|
||||
</script>
|
||||
|
||||
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show} {disabled}>
|
||||
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}>
|
||||
Import
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
|
||||
|
||||
export let resourceId
|
||||
export let disabled = false
|
||||
|
||||
let modal
|
||||
let resourcePermissions
|
||||
|
@ -14,7 +15,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<ActionButton icon="LockClosed" size="S" quiet on:click={openDropdown}>
|
||||
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}>
|
||||
Manage access
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -18,11 +18,10 @@
|
|||
|
||||
<ActionButton
|
||||
icon="Filter"
|
||||
size="S"
|
||||
quiet
|
||||
{disabled}
|
||||
on:click={modal.show}
|
||||
active={tempValue?.length > 0}
|
||||
selected={tempValue?.length > 0}
|
||||
>
|
||||
Filter
|
||||
</ActionButton>
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
<ActionButton
|
||||
icon="Filter"
|
||||
size="S"
|
||||
quiet
|
||||
on:click={modal.show}
|
||||
active={view.filters?.length}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { Modal, ActionButton } from "@budibase/bbui"
|
||||
import CreateViewModal from "../../modals/CreateViewModal.svelte"
|
||||
|
||||
const { rows, columns } = getContext("grid")
|
||||
|
||||
let modal
|
||||
|
||||
$: disabled = !$columns.length || !$rows.length
|
||||
</script>
|
||||
|
||||
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
|
||||
Create view
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<CreateViewModal />
|
||||
</Modal>
|
|
@ -0,0 +1,21 @@
|
|||
<script>
|
||||
import ExportButton from "../ExportButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { rows, columns, tableId, sort, selectedRows, filter } =
|
||||
getContext("grid")
|
||||
|
||||
$: disabled = !$rows.length || !$columns.length
|
||||
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
|
||||
</script>
|
||||
|
||||
<ExportButton
|
||||
{disabled}
|
||||
view={$tableId}
|
||||
filters={$filter}
|
||||
sorting={{
|
||||
sortColumn: $sort.column,
|
||||
sortOrder: $sort.order,
|
||||
}}
|
||||
selectedRows={selectedRowArray}
|
||||
/>
|
|
@ -0,0 +1,20 @@
|
|||
<script>
|
||||
import TableFilterButton from "../TableFilterButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { columns, config, filter, table } = getContext("grid")
|
||||
|
||||
const onFilter = e => {
|
||||
filter.set(e.detail || [])
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key $config.tableId}
|
||||
<TableFilterButton
|
||||
schema={$table?.schema}
|
||||
filters={$filter}
|
||||
on:change={onFilter}
|
||||
disabled={!$columns.length}
|
||||
tableId={$config.tableId}
|
||||
/>
|
||||
{/key}
|
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import ImportButton from "../ImportButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let disabled = false
|
||||
|
||||
const { rows, tableId } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<ImportButton
|
||||
{disabled}
|
||||
tableId={$tableId}
|
||||
on:importrows={rows.actions.refreshData}
|
||||
/>
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import ManageAccessButton from "../ManageAccessButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { config } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<ManageAccessButton resourceId={$config.tableId} />
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { table, rows } = getContext("grid")
|
||||
</script>
|
||||
|
||||
{#if $table}
|
||||
<ExistingRelationshipButton
|
||||
table={$table}
|
||||
on:updatecolumns={() => rows.actions.refreshData()}
|
||||
/>
|
||||
{/if}
|
|
@ -182,8 +182,12 @@
|
|||
indexes,
|
||||
})
|
||||
dispatch("updatecolumns")
|
||||
if (originalName) {
|
||||
notifications.success("Column updated successfully")
|
||||
} else {
|
||||
notifications.success("Column created successfully")
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
notifications.error(`Error saving column: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
@ -199,7 +203,7 @@
|
|||
notifications.error("You cannot delete the display column")
|
||||
} else {
|
||||
await tables.deleteField(editableColumn)
|
||||
notifications.success(`Column ${editableColumn.name} deleted.`)
|
||||
notifications.success(`Column ${editableColumn.name} deleted`)
|
||||
confirmDeleteDialog.hide()
|
||||
hide()
|
||||
deletion = false
|
||||
|
|
|
@ -23,9 +23,9 @@
|
|||
async function saveRow() {
|
||||
errors = []
|
||||
try {
|
||||
await API.saveRow({ ...row, tableId: table._id })
|
||||
const res = await API.saveRow({ ...row, tableId: table._id })
|
||||
notifications.success("Row saved successfully")
|
||||
dispatch("updaterows")
|
||||
dispatch("updaterows", res._id)
|
||||
} catch (error) {
|
||||
const response = error.json
|
||||
if (error.handled && response?.errors) {
|
||||
|
|
|
@ -55,9 +55,9 @@
|
|||
}
|
||||
|
||||
try {
|
||||
await API.saveRow({ ...row, tableId: table._id })
|
||||
const res = await API.saveRow({ ...row, tableId: table._id })
|
||||
notifications.success("User saved successfully")
|
||||
dispatch("updaterows")
|
||||
dispatch("updaterows", res.id)
|
||||
} catch (error) {
|
||||
if (error.handled) {
|
||||
const response = error.json
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { Modal } from "@budibase/bbui"
|
||||
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
|
||||
|
||||
const { rows, subscribe } = getContext("grid")
|
||||
|
||||
let modal
|
||||
|
||||
onMount(() => subscribe("add-column", modal.show))
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />
|
||||
</Modal>
|
|
@ -0,0 +1,28 @@
|
|||
<script>
|
||||
import CreateEditRow from "../../modals/CreateEditRow.svelte"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { Modal } from "@budibase/bbui"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
const { subscribe, rows } = getContext("grid")
|
||||
|
||||
let modal
|
||||
let row
|
||||
|
||||
onMount(() =>
|
||||
subscribe("add-row", () => {
|
||||
row = {}
|
||||
modal.show()
|
||||
})
|
||||
)
|
||||
onMount(() =>
|
||||
subscribe("edit-row", rowToEdit => {
|
||||
row = cloneDeep(rowToEdit)
|
||||
modal.show()
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<CreateEditRow {row} on:updaterows={e => rows.actions.refreshRow(e.detail)} />
|
||||
</Modal>
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { Modal } from "@budibase/bbui"
|
||||
import CreateEditColumn from "../CreateEditColumn.svelte"
|
||||
|
||||
const { rows, subscribe } = getContext("grid")
|
||||
|
||||
let editableColumn
|
||||
let editColumnModal
|
||||
|
||||
const editColumn = column => {
|
||||
editableColumn = column
|
||||
editColumnModal.show()
|
||||
}
|
||||
|
||||
onMount(() => subscribe("edit-column", editColumn))
|
||||
</script>
|
||||
|
||||
<Modal bind:this={editColumnModal}>
|
||||
<CreateEditColumn
|
||||
field={editableColumn}
|
||||
on:updatecolumns={rows.actions.refreshData}
|
||||
/>
|
||||
</Modal>
|
|
@ -0,0 +1,25 @@
|
|||
<script>
|
||||
import CreateEditUser from "../../modals/CreateEditUser.svelte"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { Modal } from "@budibase/bbui"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
const { subscribe, rows } = getContext("grid")
|
||||
|
||||
let modal
|
||||
let row
|
||||
|
||||
onMount(() =>
|
||||
subscribe("edit-row", rowToEdit => {
|
||||
row = cloneDeep(rowToEdit)
|
||||
modal.show()
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<CreateEditUser
|
||||
{row}
|
||||
on:updaterows={e => rows.actions.refreshRow(e.detail)}
|
||||
/>
|
||||
</Modal>
|
|
@ -13,6 +13,13 @@
|
|||
$: sortedTables = $tables.list
|
||||
.filter(table => table.sourceId === sourceId)
|
||||
.sort(alphabetical)
|
||||
|
||||
const selectTable = tableId => {
|
||||
tables.select(tableId)
|
||||
if (!$isActive("./table/:tableId")) {
|
||||
$goto(`./table/${tableId}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $database?._id}
|
||||
|
@ -25,7 +32,7 @@
|
|||
text={table.name}
|
||||
selected={$isActive("./table/:tableId") &&
|
||||
$tables.selected?._id === table._id}
|
||||
on:click={() => $goto(`./table/${table._id}`)}
|
||||
on:click={() => selectTable(table._id)}
|
||||
>
|
||||
{#if table._id !== TableNames.USERS}
|
||||
<EditTablePopover {table} />
|
||||
|
|
|
@ -325,9 +325,4 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -59,9 +59,7 @@
|
|||
<div class="app-row-actions">
|
||||
<AppLockModal {app} buttonSize="M" />
|
||||
<Button size="S" secondary on:click={goToOverview}>Manage</Button>
|
||||
<Button size="S" primary disabled={app.lockedOther} on:click={goToBuilder}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="S" primary on:click={goToBuilder}>Edit</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -70,7 +70,6 @@ a {
|
|||
background: var(--spectrum-alias-background-color-default);
|
||||
}
|
||||
html * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--spectrum-global-color-gray-400)
|
||||
var(--spectrum-alias-background-color-default);
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
"dataprovider",
|
||||
"repeater",
|
||||
"table",
|
||||
"spreadsheet",
|
||||
"dynamicfilter",
|
||||
"daterangepicker"
|
||||
]
|
||||
|
|
|
@ -135,6 +135,24 @@ export function createTablesStore() {
|
|||
await save(draft)
|
||||
}
|
||||
|
||||
const updateTable = table => {
|
||||
const index = get(store).list.findIndex(x => x._id === table._id)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
// This function has to merge state as there discrepancies with the table
|
||||
// API endpoints. The table list endpoint and get table endpoint use the
|
||||
// "type" property to mean different things.
|
||||
store.update(state => {
|
||||
state.list[index] = {
|
||||
...table,
|
||||
type: state.list[index].type,
|
||||
}
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: derivedStore.subscribe,
|
||||
fetch,
|
||||
|
@ -145,6 +163,7 @@ export function createTablesStore() {
|
|||
delete: deleteTable,
|
||||
saveField,
|
||||
deleteField,
|
||||
updateTable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5161,5 +5161,36 @@
|
|||
"type": "schema",
|
||||
"suffix": "repeater"
|
||||
}
|
||||
},
|
||||
"spreadsheet": {
|
||||
"name": "Spreadsheet",
|
||||
"icon": "ViewGrid",
|
||||
"settings": [
|
||||
{
|
||||
"key": "table",
|
||||
"type": "table",
|
||||
"label": "Table"
|
||||
},
|
||||
{
|
||||
"type": "filter",
|
||||
"label": "Filtering",
|
||||
"key": "filter"
|
||||
},
|
||||
{
|
||||
"type": "field/sortable",
|
||||
"label": "Sort Column",
|
||||
"key": "sortColumn"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Sort Order",
|
||||
"key": "sortOrder",
|
||||
"options": [
|
||||
"Ascending",
|
||||
"Descending"
|
||||
],
|
||||
"defaultValue": "Ascending"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
|
||||
<svelte:head>
|
||||
{#if $builderStore.usedPlugins?.length}
|
||||
{#each $builderStore.usedPlugins as plugin}
|
||||
{#each $builderStore.usedPlugins as plugin (plugin.hash)}
|
||||
<script src={`${plugin.jsUrl}`}></script>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
environmentStore,
|
||||
sidePanelStore,
|
||||
dndIsDragging,
|
||||
confirmationStore,
|
||||
} from "stores"
|
||||
import { styleable } from "utils/styleable"
|
||||
import { linkable } from "utils/linkable"
|
||||
|
@ -35,6 +36,7 @@ export default {
|
|||
sidePanelStore,
|
||||
dndIsDragging,
|
||||
currentRole,
|
||||
confirmationStore,
|
||||
styleable,
|
||||
linkable,
|
||||
getAction,
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
export const domDebounce = callback => {
|
||||
export const domDebounce = (callback, extractParams = x => x) => {
|
||||
let active = false
|
||||
return e => {
|
||||
let lastParams
|
||||
return (...params) => {
|
||||
lastParams = extractParams(...params)
|
||||
if (!active) {
|
||||
window.requestAnimationFrame(() => {
|
||||
callback(e)
|
||||
active = true
|
||||
requestAnimationFrame(() => {
|
||||
callback(lastParams)
|
||||
active = false
|
||||
})
|
||||
active = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
"dependencies": {
|
||||
"@budibase/bbui": "2.5.6-alpha.6",
|
||||
"@budibase/shared-core": "2.5.6-alpha.6",
|
||||
"dayjs": "^1.11.7",
|
||||
"lodash": "^4.17.21",
|
||||
"socket.io-client": "^4.6.1",
|
||||
"svelte": "^3.46.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { getContext } from "svelte"
|
||||
import { Dropzone, notifications } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
export let focused = false
|
||||
export let onChange
|
||||
export let readonly = false
|
||||
export let api
|
||||
export let invertX = false
|
||||
export let invertY = false
|
||||
|
||||
const { API } = getContext("grid")
|
||||
const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"]
|
||||
|
||||
let isOpen = false
|
||||
|
||||
$: editable = focused && !readonly
|
||||
$: {
|
||||
if (!focused) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = () => {
|
||||
return isOpen
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
isOpen = true
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
const isImage = extension => {
|
||||
return imageExtensions.includes(extension?.toLowerCase())
|
||||
}
|
||||
|
||||
const handleFileTooLarge = fileSizeLimit => {
|
||||
notifications.error(
|
||||
`Files cannot exceed ${
|
||||
fileSizeLimit / 1000000
|
||||
}MB. Please try again with smaller files.`
|
||||
)
|
||||
}
|
||||
|
||||
const processFiles = async fileList => {
|
||||
let data = new FormData()
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
data.append("file", fileList[i])
|
||||
}
|
||||
try {
|
||||
return await API.uploadBuilderAttachment(data)
|
||||
} catch (error) {
|
||||
notifications.error("Failed to upload attachment")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAttachments = async fileList => {
|
||||
try {
|
||||
return await API.deleteBuilderAttachments(fileList)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
focus: () => open(),
|
||||
blur: () => close(),
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="attachment-cell" class:editable on:click={editable ? open : null}>
|
||||
{#each value || [] as attachment}
|
||||
{#if isImage(attachment.extension)}
|
||||
<img src={attachment.url} alt={attachment.extension} />
|
||||
{:else}
|
||||
<div class="file" title={attachment.name}>
|
||||
{attachment.extension}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropzone" class:invertX class:invertY>
|
||||
<Dropzone
|
||||
{value}
|
||||
compact
|
||||
on:change={e => onChange(e.detail)}
|
||||
{processFiles}
|
||||
{deleteAttachments}
|
||||
{handleFileTooLarge}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.attachment-cell {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
padding: var(--cell-padding);
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--cell-spacing);
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.attachment-cell.editable:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
.dropzone {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 320px;
|
||||
background: var(--background);
|
||||
border: var(--cell-border);
|
||||
padding: var(--cell-padding);
|
||||
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dropzone.invertX {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.dropzone.invertY {
|
||||
transform: translateY(-100%);
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { Checkbox } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
export let focused = false
|
||||
export let onChange
|
||||
export let readonly = false
|
||||
export let api
|
||||
|
||||
$: editable = focused && !readonly
|
||||
|
||||
const handleChange = e => {
|
||||
onChange(e.detail)
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (e.key === "Enter") {
|
||||
onChange(!value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="boolean-cell" class:editable>
|
||||
<Checkbox {value} on:change={handleChange} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.boolean-cell {
|
||||
padding: 2px var(--cell-padding);
|
||||
pointer-events: none;
|
||||
}
|
||||
.boolean-cell.editable {
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,86 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import GridCell from "./GridCell.svelte"
|
||||
import { getCellRenderer } from "../lib/renderers"
|
||||
import { derived, writable } from "svelte/store"
|
||||
|
||||
const { rows, focusedCellId, focusedCellAPI, menu, config, validation } =
|
||||
getContext("grid")
|
||||
|
||||
export let highlighted
|
||||
export let selected
|
||||
export let rowFocused
|
||||
export let rowIdx
|
||||
export let focused
|
||||
export let selectedUser
|
||||
export let column
|
||||
export let row
|
||||
export let cellId
|
||||
export let updateValue = rows.actions.updateValue
|
||||
export let invertX = false
|
||||
export let invertY = false
|
||||
|
||||
const emptyError = writable(null)
|
||||
|
||||
let api
|
||||
|
||||
// Get the error for this cell if the row is focused
|
||||
$: error = getErrorStore(rowFocused, cellId)
|
||||
|
||||
// Determine if the cell is editable
|
||||
$: readonly =
|
||||
column.schema.autocolumn ||
|
||||
column.schema.disabled ||
|
||||
(!$config.allowEditRows && row._id)
|
||||
|
||||
// Register this cell API if the row is focused
|
||||
$: {
|
||||
if (focused) {
|
||||
focusedCellAPI.set(cellAPI)
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorStore = (selected, cellId) => {
|
||||
if (!selected) {
|
||||
return emptyError
|
||||
}
|
||||
return derived(validation, $validation => $validation[cellId])
|
||||
}
|
||||
|
||||
const cellAPI = {
|
||||
focus: () => api?.focus(),
|
||||
blur: () => api?.blur(),
|
||||
onKeyDown: (...params) => api?.onKeyDown(...params),
|
||||
isReadonly: () => readonly,
|
||||
getType: () => column.schema.type,
|
||||
getValue: () => row[column.name],
|
||||
setValue: value => {
|
||||
validation.actions.setError(cellId, null)
|
||||
updateValue(row._id, column.name, value)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<GridCell
|
||||
{highlighted}
|
||||
{selected}
|
||||
{rowIdx}
|
||||
{focused}
|
||||
{selectedUser}
|
||||
error={$error}
|
||||
on:click={() => focusedCellId.set(cellId)}
|
||||
on:contextmenu={e => menu.actions.open(cellId, e)}
|
||||
width={column.width}
|
||||
>
|
||||
<svelte:component
|
||||
this={getCellRenderer(column)}
|
||||
bind:api
|
||||
value={row[column.name]}
|
||||
schema={column.schema}
|
||||
onChange={cellAPI.setValue}
|
||||
{focused}
|
||||
{readonly}
|
||||
{invertY}
|
||||
{invertX}
|
||||
/>
|
||||
</GridCell>
|
|
@ -0,0 +1,77 @@
|
|||
<script>
|
||||
import dayjs from "dayjs"
|
||||
import { CoreDatePicker, Icon } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
export let onChange
|
||||
export let focused = false
|
||||
export let readonly = false
|
||||
|
||||
// adding the 0- will turn a string like 00:00:00 into a valid ISO
|
||||
// date, but will make actual ISO dates invalid
|
||||
$: time = new Date(`0-${value}`)
|
||||
$: timeOnly = !isNaN(time) || schema?.timeOnly
|
||||
$: dateOnly = schema?.dateOnly
|
||||
$: format = timeOnly
|
||||
? "HH:mm:ss"
|
||||
: dateOnly
|
||||
? "MMM D YYYY"
|
||||
: "MMM D YYYY, HH:mm"
|
||||
$: editable = focused && !readonly
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="value">
|
||||
{#if value}
|
||||
{dayjs(timeOnly ? time : value).format(format)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if editable}
|
||||
<Icon name="Calendar" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editable}
|
||||
<div class="picker">
|
||||
<CoreDatePicker
|
||||
{value}
|
||||
on:change={e => onChange(e.detail)}
|
||||
appendTo={document.documentElement}
|
||||
enableTime={!dateOnly}
|
||||
{timeOnly}
|
||||
time24hr
|
||||
ignoreTimezones={schema.ignoreTimezones}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: var(--cell-padding);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--cell-spacing);
|
||||
}
|
||||
.value {
|
||||
flex: 1 1 auto;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
.picker {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
.picker :global(.flatpickr) {
|
||||
min-width: 0;
|
||||
}
|
||||
.picker :global(.spectrum-Textfield-input) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
import TextCell from "./TextCell.svelte"
|
||||
</script>
|
||||
|
||||
<TextCell {...$$props} readonly />
|
|
@ -0,0 +1,159 @@
|
|||
<script>
|
||||
export let focused = false
|
||||
export let selected = false
|
||||
export let highlighted = false
|
||||
export let width = ""
|
||||
export let selectedUser = null
|
||||
export let error = null
|
||||
export let rowIdx
|
||||
export let defaultHeight = false
|
||||
export let center = false
|
||||
|
||||
$: style = getStyle(width, selectedUser)
|
||||
|
||||
const getStyle = (width, selectedUser) => {
|
||||
let style = `flex: 0 0 ${width}px;`
|
||||
if (selectedUser) {
|
||||
style += `--cell-color:${selectedUser.color};`
|
||||
}
|
||||
return style
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="cell"
|
||||
class:selected
|
||||
class:highlighted
|
||||
class:focused
|
||||
class:error
|
||||
class:center
|
||||
class:default-height={defaultHeight}
|
||||
class:selected-other={selectedUser != null}
|
||||
on:focus
|
||||
on:mousedown
|
||||
on:mouseup
|
||||
on:click
|
||||
on:contextmenu
|
||||
{style}
|
||||
data-row={rowIdx}
|
||||
>
|
||||
{#if error}
|
||||
<div class="label">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
{#if selectedUser && !focused}
|
||||
<div class="label">
|
||||
{selectedUser.label}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Cells */
|
||||
.cell {
|
||||
height: var(--row-height);
|
||||
border-bottom: var(--cell-border);
|
||||
border-right: var(--cell-border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
font-size: var(--cell-font-size);
|
||||
gap: var(--cell-spacing);
|
||||
background: var(--cell-background);
|
||||
position: relative;
|
||||
width: 0;
|
||||
--cell-color: transparent;
|
||||
}
|
||||
.cell.default-height {
|
||||
height: var(--default-row-height);
|
||||
}
|
||||
.cell.center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Cell border */
|
||||
.cell.focused:after,
|
||||
.cell.error:after,
|
||||
.cell.selected-other:not(.focused):after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 2px solid var(--cell-color);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Cell border for cells with labels */
|
||||
.cell.error:after,
|
||||
.cell.selected-other:not(.focused):after {
|
||||
border-radius: 0 2px 2px 2px;
|
||||
}
|
||||
.cell[data-row="0"].error:after,
|
||||
.cell[data-row="0"].selected-other:not(.focused):after {
|
||||
border-radius: 2px 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Cell z-index */
|
||||
.cell.error,
|
||||
.cell.selected-other:not(.focused) {
|
||||
z-index: 1;
|
||||
}
|
||||
.cell.focused {
|
||||
z-index: 2;
|
||||
}
|
||||
.cell.focused {
|
||||
--cell-color: var(--spectrum-global-color-blue-400);
|
||||
}
|
||||
.cell.error {
|
||||
--cell-color: var(--spectrum-global-color-red-500);
|
||||
}
|
||||
.cell:not(.focused) {
|
||||
user-select: none;
|
||||
}
|
||||
.cell:hover {
|
||||
cursor: default;
|
||||
}
|
||||
.cell.highlighted:not(.focused) {
|
||||
--cell-background: var(--cell-background-hover);
|
||||
}
|
||||
.cell.selected:not(.focused) {
|
||||
--cell-background: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
|
||||
/* Label for additional text */
|
||||
.label {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
padding: 1px 4px 3px 4px;
|
||||
margin: 0 0 -2px 0;
|
||||
background: var(--user-color);
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.cell[data-row="0"] .label {
|
||||
bottom: auto;
|
||||
top: 100%;
|
||||
border-radius: 0 2px 2px 2px;
|
||||
padding: 2px 4px 2px 4px;
|
||||
margin: -2px 0 0 0;
|
||||
}
|
||||
.error .label {
|
||||
background: var(--spectrum-global-color-red-500);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,237 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import GridCell from "./GridCell.svelte"
|
||||
import { Icon, Popover, Menu, MenuItem } from "@budibase/bbui"
|
||||
import { getColumnIcon } from "../lib/utils"
|
||||
|
||||
export let column
|
||||
export let idx
|
||||
export let orderable = true
|
||||
|
||||
const {
|
||||
reorder,
|
||||
isReordering,
|
||||
isResizing,
|
||||
rand,
|
||||
sort,
|
||||
renderedColumns,
|
||||
dispatch,
|
||||
config,
|
||||
ui,
|
||||
columns,
|
||||
} = getContext("grid")
|
||||
const bannedDisplayColumnTypes = [
|
||||
"link",
|
||||
"array",
|
||||
"attachment",
|
||||
"boolean",
|
||||
"formula",
|
||||
"json",
|
||||
]
|
||||
|
||||
let anchor
|
||||
let open = false
|
||||
let timeout
|
||||
|
||||
$: sortedBy = column.name === $sort.column
|
||||
$: canMoveLeft = orderable && idx > 0
|
||||
$: canMoveRight = orderable && idx < $renderedColumns.length - 1
|
||||
|
||||
const editColumn = () => {
|
||||
dispatch("edit-column", column.schema)
|
||||
open = false
|
||||
}
|
||||
|
||||
const onMouseDown = e => {
|
||||
if (e.button === 0 && orderable) {
|
||||
timeout = setTimeout(() => {
|
||||
reorder.actions.startReordering(column.name, e)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = e => {
|
||||
if (e.button === 0 && orderable) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const onContextMenu = e => {
|
||||
e.preventDefault()
|
||||
ui.actions.blur()
|
||||
open = !open
|
||||
}
|
||||
|
||||
const sortAscending = () => {
|
||||
sort.set({
|
||||
column: column.name,
|
||||
order: "ascending",
|
||||
})
|
||||
open = false
|
||||
}
|
||||
|
||||
const sortDescending = () => {
|
||||
sort.set({
|
||||
column: column.name,
|
||||
order: "descending",
|
||||
})
|
||||
open = false
|
||||
}
|
||||
|
||||
const moveLeft = () => {
|
||||
reorder.actions.moveColumnLeft(column.name)
|
||||
open = false
|
||||
}
|
||||
|
||||
const moveRight = () => {
|
||||
reorder.actions.moveColumnRight(column.name)
|
||||
open = false
|
||||
}
|
||||
|
||||
const makeDisplayColumn = () => {
|
||||
columns.actions.changePrimaryDisplay(column.name)
|
||||
open = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="header-cell"
|
||||
class:open
|
||||
style="flex: 0 0 {column.width}px;"
|
||||
bind:this={anchor}
|
||||
class:disabled={$isReordering || $isResizing}
|
||||
class:sorted={sortedBy}
|
||||
>
|
||||
<GridCell
|
||||
on:mousedown={onMouseDown}
|
||||
on:mouseup={onMouseUp}
|
||||
on:contextmenu={onContextMenu}
|
||||
width={column.width}
|
||||
left={column.left}
|
||||
defaultHeight
|
||||
center
|
||||
>
|
||||
<Icon
|
||||
size="S"
|
||||
name={getColumnIcon(column)}
|
||||
color={`var(--spectrum-global-color-gray-600)`}
|
||||
/>
|
||||
<div class="name">
|
||||
{column.label}
|
||||
</div>
|
||||
{#if sortedBy}
|
||||
<div class="sort-indicator">
|
||||
<Icon
|
||||
size="S"
|
||||
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"}
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="more"
|
||||
on:mousedown|stopPropagation
|
||||
on:click={() => (open = true)}
|
||||
>
|
||||
<Icon
|
||||
size="S"
|
||||
name="MoreVertical"
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
/>
|
||||
</div>
|
||||
</GridCell>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
bind:open
|
||||
{anchor}
|
||||
align="right"
|
||||
offset={0}
|
||||
popoverTarget={document.getElementById(`grid-${rand}`)}
|
||||
animate={false}
|
||||
>
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="Edit"
|
||||
on:click={editColumn}
|
||||
disabled={!$config.allowEditColumns || column.schema.disabled}
|
||||
>
|
||||
Edit column
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Label"
|
||||
on:click={makeDisplayColumn}
|
||||
disabled={idx === "sticky" ||
|
||||
!$config.allowEditColumns ||
|
||||
bannedDisplayColumnTypes.includes(column.schema.type)}
|
||||
>
|
||||
Use as display column
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="SortOrderUp"
|
||||
on:click={sortAscending}
|
||||
disabled={column.name === $sort.column && $sort.order === "ascending"}
|
||||
>
|
||||
Sort A-Z
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="SortOrderDown"
|
||||
on:click={sortDescending}
|
||||
disabled={column.name === $sort.column && $sort.order === "descending"}
|
||||
>
|
||||
Sort Z-A
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
|
||||
Move left
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!canMoveRight} icon="ChevronRight" on:click={moveRight}>
|
||||
Move right
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.header-cell {
|
||||
display: flex;
|
||||
}
|
||||
.header-cell.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
.header-cell :global(.cell) {
|
||||
padding: 0 var(--cell-padding);
|
||||
gap: calc(2 * var(--cell-spacing));
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
}
|
||||
.header-cell.sorted :global(.cell) {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1 1 auto;
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: none;
|
||||
padding: 4px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
.header-cell.open .more,
|
||||
.header-cell:hover .more {
|
||||
display: block;
|
||||
}
|
||||
.more:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.more:hover :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-800) !important;
|
||||
}
|
||||
|
||||
.header-cell.open .sort-indicator,
|
||||
.header-cell:hover .sort-indicator {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,36 @@
|
|||
<script>
|
||||
import LongFormCell from "./LongFormCell.svelte"
|
||||
|
||||
export let onChange
|
||||
export let value
|
||||
export let api
|
||||
|
||||
$: stringified = getStringifiedValue(value)
|
||||
|
||||
const getStringifiedValue = value => {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parse = value => {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
onChange(parsed)
|
||||
} catch (error) {
|
||||
// Swallow
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LongFormCell {...$$props} bind:api value={stringified} onChange={parse} />
|
|
@ -0,0 +1,117 @@
|
|||
<script>
|
||||
import { onMount, tick } from "svelte"
|
||||
|
||||
export let value
|
||||
export let focused = false
|
||||
export let onChange
|
||||
export let readonly = false
|
||||
export let api
|
||||
export let invertX = false
|
||||
export let invertY = false
|
||||
|
||||
let textarea
|
||||
let isOpen = false
|
||||
|
||||
$: editable = focused && !readonly
|
||||
$: {
|
||||
if (!focused) {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = e => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
const onKeyDown = () => {
|
||||
return isOpen
|
||||
}
|
||||
|
||||
const open = async () => {
|
||||
isOpen = true
|
||||
await tick()
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
textarea?.blur()
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
focus: () => open(),
|
||||
blur: () => close(),
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<textarea
|
||||
class:invertX
|
||||
class:invertY
|
||||
bind:this={textarea}
|
||||
value={value || ""}
|
||||
on:change={handleChange}
|
||||
on:wheel|stopPropagation
|
||||
spellcheck="false"
|
||||
/>
|
||||
{:else}
|
||||
<div class="long-form-cell" on:click={editable ? open : null} class:editable>
|
||||
<div class="value">
|
||||
{value || ""}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.long-form-cell {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--cell-padding);
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
.long-form-cell.editable:hover {
|
||||
cursor: text;
|
||||
}
|
||||
.value {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: var(--content-lines);
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 20px;
|
||||
}
|
||||
textarea {
|
||||
padding: var(--cell-padding);
|
||||
margin: 0;
|
||||
border: 2px solid var(--cell-color);
|
||||
background: var(--cell-background);
|
||||
font-size: var(--cell-font-size);
|
||||
font-family: var(--font-sans);
|
||||
color: inherit;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% + var(--max-cell-render-width-overflow));
|
||||
height: var(--max-cell-render-height);
|
||||
z-index: 1;
|
||||
border-radius: 2px;
|
||||
resize: none;
|
||||
line-height: 20px;
|
||||
}
|
||||
textarea.invertX {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
textarea.invertY {
|
||||
transform: translateY(-100%);
|
||||
top: calc(100% + 1px);
|
||||
}
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import OptionsCell from "./OptionsCell.svelte"
|
||||
|
||||
export let api
|
||||
</script>
|
||||
|
||||
<OptionsCell bind:api {...$$props} multi />
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import TextCell from "./TextCell.svelte"
|
||||
|
||||
export let api
|
||||
</script>
|
||||
|
||||
<TextCell {...$$props} bind:api type="number" />
|
|
@ -0,0 +1,239 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { getColor } from "../lib/utils"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
export let onChange
|
||||
export let focused = false
|
||||
export let multi = false
|
||||
export let readonly = false
|
||||
export let api
|
||||
export let invertX = false
|
||||
export let invertY = false
|
||||
|
||||
let isOpen = false
|
||||
let focusedOptionIdx = null
|
||||
|
||||
$: options = schema?.constraints?.inclusion || []
|
||||
$: editable = focused && !readonly
|
||||
$: values = Array.isArray(value) ? value : [value].filter(x => x != null)
|
||||
$: {
|
||||
// Close when deselected
|
||||
if (!focused) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
isOpen = true
|
||||
focusedOptionIdx = 0
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
const getOptionColor = value => {
|
||||
const index = value ? options.indexOf(value) : null
|
||||
return getColor(index)
|
||||
}
|
||||
|
||||
const toggleOption = option => {
|
||||
if (!multi) {
|
||||
onChange(option === value ? null : option)
|
||||
close()
|
||||
} else {
|
||||
const sanitizedValues = values.filter(x => options.includes(x))
|
||||
if (sanitizedValues.includes(option)) {
|
||||
onChange(sanitizedValues.filter(x => x !== option))
|
||||
} else {
|
||||
onChange([...sanitizedValues, option])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (!isOpen) {
|
||||
return false
|
||||
}
|
||||
e.preventDefault()
|
||||
if (e.key === "ArrowDown") {
|
||||
focusedOptionIdx = Math.min(focusedOptionIdx + 1, options.length - 1)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
focusedOptionIdx = Math.max(focusedOptionIdx - 1, 0)
|
||||
} else if (e.key === "Enter") {
|
||||
toggleOption(options[focusedOptionIdx])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
focus: open,
|
||||
blur: close,
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="container"
|
||||
class:multi
|
||||
class:editable
|
||||
class:open
|
||||
on:click|self={editable ? open : null}
|
||||
>
|
||||
<div class="values" on:click={editable ? open : null}>
|
||||
{#each values as val}
|
||||
{@const color = getOptionColor(val)}
|
||||
{#if color}
|
||||
<div class="badge text" style="--color: {color}">
|
||||
<span>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text">
|
||||
{val || ""}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if editable}
|
||||
<div class="arrow" on:click={open}>
|
||||
<Icon name="ChevronDown" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="options"
|
||||
class:invertX
|
||||
class:invertY
|
||||
on:wheel={e => e.stopPropagation()}
|
||||
>
|
||||
{#each options as option, idx}
|
||||
{@const color = getOptionColor(option)}
|
||||
<div
|
||||
class="option"
|
||||
on:click={() => toggleOption(option)}
|
||||
class:focused={focusedOptionIdx === idx}
|
||||
on:mouseenter={() => (focusedOptionIdx = idx)}
|
||||
>
|
||||
<div class="badge text" style="--color: {color}">
|
||||
{option}
|
||||
</div>
|
||||
{#if values.includes(option)}
|
||||
<Icon
|
||||
name="Checkmark"
|
||||
color="var(--spectrum-global-color-blue-400)"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.container.editable:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex: 1 1 auto;
|
||||
grid-column-gap: var(--cell-spacing);
|
||||
grid-row-gap: var(--cell-padding);
|
||||
overflow: hidden;
|
||||
padding: var(--cell-padding);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.multi .text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.badge {
|
||||
padding: 0 var(--cell-padding);
|
||||
background: var(--color);
|
||||
border-radius: var(--cell-padding);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cell-spacing);
|
||||
height: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.badge span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
bottom: 2px;
|
||||
padding: 0 6px 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
var(--cell-background) 40%
|
||||
);
|
||||
}
|
||||
.options {
|
||||
min-width: calc(100% + 2px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: -1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
max-height: var(--max-cell-render-height);
|
||||
overflow-y: auto;
|
||||
border: var(--cell-border);
|
||||
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.options.invertX {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.options.invertY {
|
||||
transform: translateY(-100%);
|
||||
top: 0;
|
||||
}
|
||||
.option {
|
||||
flex: 0 0 var(--default-row-height);
|
||||
padding: 0 var(--cell-padding);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--cell-spacing);
|
||||
background-color: var(--background);
|
||||
}
|
||||
.option:hover,
|
||||
.option.focused {
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,503 @@
|
|||
<script context="module">
|
||||
// We can create a module level cache for all relationship cells to avoid
|
||||
// having to fetch the table definition one time for each cell
|
||||
let primaryDisplayCache = {}
|
||||
|
||||
const getPrimaryDisplayForTableId = async (API, tableId) => {
|
||||
if (primaryDisplayCache[tableId]) {
|
||||
return primaryDisplayCache[tableId]
|
||||
}
|
||||
const definition = await API.fetchTableDefinition(tableId)
|
||||
const primaryDisplay =
|
||||
definition?.primaryDisplay || definition?.schema?.[0]?.name
|
||||
primaryDisplayCache[tableId] = primaryDisplay
|
||||
return primaryDisplay
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { getColor } from "../lib/utils"
|
||||
import { onMount, getContext } from "svelte"
|
||||
import { Icon, Input, ProgressCircle } from "@budibase/bbui"
|
||||
import { debounce } from "../../../utils/utils"
|
||||
|
||||
export let value
|
||||
export let api
|
||||
export let readonly
|
||||
export let focused
|
||||
export let schema
|
||||
export let onChange
|
||||
export let invertX = false
|
||||
export let invertY = false
|
||||
|
||||
const { API, dispatch } = getContext("grid")
|
||||
const color = getColor(0)
|
||||
|
||||
let isOpen = false
|
||||
let searchResults
|
||||
let searchString
|
||||
let lastSearchString
|
||||
let primaryDisplay
|
||||
let candidateIndex
|
||||
let lastSearchId
|
||||
let searching = false
|
||||
|
||||
$: oneRowOnly = schema?.relationshipType === "one-to-many"
|
||||
$: editable = focused && !readonly
|
||||
$: lookupMap = buildLookupMap(value, isOpen)
|
||||
$: debouncedSearch(searchString)
|
||||
$: {
|
||||
if (!focused) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a lookup map to quickly check which rows are selected
|
||||
const buildLookupMap = (value, isOpen) => {
|
||||
let map = {}
|
||||
if (!isOpen || !value?.length) {
|
||||
return map
|
||||
}
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
map[value[i]._id] = true
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Checks if a certain row is currently selected
|
||||
const isRowSelected = row => {
|
||||
if (!row?._id) {
|
||||
return false
|
||||
}
|
||||
return lookupMap?.[row._id] === true
|
||||
}
|
||||
|
||||
// Search for rows based on the search string
|
||||
const search = async (searchString, force = false) => {
|
||||
// Avoid update state at all if we've already handled the update and this is
|
||||
// a wasted search due to svelte reactivity
|
||||
if (!force && !searchString && !lastSearchString) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset state if this search is invalid
|
||||
if (!schema?.tableId || !isOpen) {
|
||||
lastSearchString = null
|
||||
candidateIndex = null
|
||||
searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
// Search for results, using IDs to track invocations and ensure we're
|
||||
// handling the latest update
|
||||
lastSearchId = Math.random()
|
||||
searching = true
|
||||
const thisSearchId = lastSearchId
|
||||
const results = await API.searchTable({
|
||||
paginate: false,
|
||||
tableId: schema.tableId,
|
||||
limit: 20,
|
||||
query: {
|
||||
string: {
|
||||
[`1:${primaryDisplay}`]: searchString || "",
|
||||
},
|
||||
},
|
||||
})
|
||||
searching = false
|
||||
|
||||
// In case searching takes longer than our debounced update, abandon these
|
||||
// results
|
||||
if (thisSearchId !== lastSearchId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sort and process results
|
||||
searchResults = sortRows(
|
||||
results.rows?.map(row => ({
|
||||
...row,
|
||||
primaryDisplay: row[primaryDisplay],
|
||||
}))
|
||||
)
|
||||
candidateIndex = searchResults?.length ? 0 : null
|
||||
lastSearchString = searchString
|
||||
}
|
||||
|
||||
// Debounced version of searching
|
||||
const debouncedSearch = debounce(search, 250)
|
||||
|
||||
// Alphabetically sorts rows by their primary display column
|
||||
const sortRows = rows => {
|
||||
if (!rows?.length) {
|
||||
return []
|
||||
}
|
||||
return rows.slice().sort((a, b) => {
|
||||
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
const open = async () => {
|
||||
isOpen = true
|
||||
|
||||
// Find the primary display for the related table
|
||||
if (!primaryDisplay) {
|
||||
searching = true
|
||||
primaryDisplay = await getPrimaryDisplayForTableId(API, schema.tableId)
|
||||
}
|
||||
|
||||
// Show initial list of results
|
||||
await search(null, true)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen = false
|
||||
searchResults = []
|
||||
searchString = null
|
||||
lastSearchString = null
|
||||
candidateIndex = null
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (!isOpen) {
|
||||
return false
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
// Select next result on down arrow
|
||||
e.preventDefault()
|
||||
if (candidateIndex == null) {
|
||||
candidateIndex = 0
|
||||
} else {
|
||||
candidateIndex = Math.min(searchResults.length - 1, candidateIndex + 1)
|
||||
}
|
||||
} else if (e.key === "ArrowUp") {
|
||||
// Select previous result on up array
|
||||
e.preventDefault()
|
||||
if (candidateIndex === 0) {
|
||||
candidateIndex = null
|
||||
} else if (candidateIndex != null) {
|
||||
candidateIndex = Math.max(0, candidateIndex - 1)
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
// Toggle the highlighted result on enter press
|
||||
if (candidateIndex != null && searchResults[candidateIndex] != null) {
|
||||
toggleRow(searchResults[candidateIndex])
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Toggles whether a row is included in the relationship or not
|
||||
const toggleRow = async row => {
|
||||
if (value?.some(x => x._id === row._id)) {
|
||||
// If the row is already included, remove it and update the candidate
|
||||
// row to be the the same position if possible
|
||||
if (oneRowOnly) {
|
||||
await onChange([])
|
||||
} else {
|
||||
const newValue = value.filter(x => x._id !== row._id)
|
||||
if (!newValue.length) {
|
||||
candidateIndex = null
|
||||
} else {
|
||||
candidateIndex = Math.min(candidateIndex, newValue.length - 1)
|
||||
}
|
||||
await onChange(newValue)
|
||||
}
|
||||
} else {
|
||||
// If we don't have this row, include it
|
||||
if (oneRowOnly) {
|
||||
await onChange([row])
|
||||
} else {
|
||||
await onChange(sortRows([...(value || []), row]))
|
||||
}
|
||||
candidateIndex = null
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const showRelationship = async id => {
|
||||
const relatedRow = await API.fetchRow({
|
||||
tableId: schema.tableId,
|
||||
rowId: id,
|
||||
})
|
||||
dispatch("edit-row", relatedRow)
|
||||
}
|
||||
|
||||
const readable = value => {
|
||||
if (value == null) {
|
||||
return ""
|
||||
}
|
||||
if (value instanceof Object) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
focus: open,
|
||||
blur: close,
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="wrapper" class:editable class:focused style="--color:{color};">
|
||||
<div class="container">
|
||||
<div class="values" on:wheel={e => (focused ? e.stopPropagation() : null)}>
|
||||
{#each value || [] as relationship, idx}
|
||||
{#if relationship.primaryDisplay}
|
||||
<div class="badge">
|
||||
<span
|
||||
on:click={editable
|
||||
? () => showRelationship(relationship._id)
|
||||
: null}
|
||||
>
|
||||
{readable(relationship.primaryDisplay)}
|
||||
</span>
|
||||
{#if editable}
|
||||
<Icon
|
||||
name="Close"
|
||||
size="XS"
|
||||
hoverable
|
||||
on:click={() => toggleRow(relationship)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if editable}
|
||||
<div class="add" on:click={open}>
|
||||
<Icon name="Add" size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if value?.length}
|
||||
<div class="count">
|
||||
{value?.length || 0}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown" class:invertX class:invertY on:wheel|stopPropagation>
|
||||
<div class="search">
|
||||
<Input
|
||||
autofocus
|
||||
quiet
|
||||
type="text"
|
||||
bind:value={searchString}
|
||||
placeholder={primaryDisplay ? `Search by ${primaryDisplay}` : null}
|
||||
/>
|
||||
</div>
|
||||
{#if searching}
|
||||
<div class="searching">
|
||||
<ProgressCircle size="S" />
|
||||
</div>
|
||||
{:else if searchResults?.length}
|
||||
<div class="results">
|
||||
{#each searchResults as row, idx}
|
||||
<div
|
||||
class="result"
|
||||
on:click={() => toggleRow(row)}
|
||||
class:candidate={idx === candidateIndex}
|
||||
on:mouseenter={() => (candidateIndex = idx)}
|
||||
>
|
||||
<div class="badge">
|
||||
<span>
|
||||
{readable(row.primaryDisplay)}
|
||||
</span>
|
||||
</div>
|
||||
{#if isRowSelected(row)}
|
||||
<Icon
|
||||
size="S"
|
||||
name="Checkmark"
|
||||
color="var(--spectrum-global-color-blue-400)"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
flex: 1 1 auto;
|
||||
align-self: flex-start;
|
||||
min-height: var(--row-height);
|
||||
max-height: var(--row-height);
|
||||
overflow: hidden;
|
||||
--max-relationship-height: 120px;
|
||||
}
|
||||
.wrapper.focused {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: var(--cell-background);
|
||||
z-index: 1;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: var(--row-height);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.focused .container {
|
||||
overflow-y: auto;
|
||||
border-radius: 2px;
|
||||
max-height: var(--max-relationship-height);
|
||||
}
|
||||
.focused .container:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 2px solid var(--cell-color);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex: 1 1 auto;
|
||||
grid-column-gap: var(--cell-spacing);
|
||||
grid-row-gap: var(--cell-padding);
|
||||
overflow: hidden;
|
||||
padding: var(--cell-padding);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.count {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
padding: var(--cell-padding) var(--cell-padding) var(--cell-padding) 20px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
var(--cell-background) 40%
|
||||
);
|
||||
}
|
||||
.wrapper:hover:not(.focused) .count {
|
||||
display: block;
|
||||
}
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 var(--cell-padding);
|
||||
background: var(--color);
|
||||
border-radius: var(--cell-padding);
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cell-spacing);
|
||||
height: 20px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.badge span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.editable .values .badge span:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.add {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.add :global(.spectrum-Icon) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: calc(
|
||||
var(--max-cell-render-height) + var(--row-height) -
|
||||
var(--max-relationship-height)
|
||||
);
|
||||
background: var(--background);
|
||||
border: var(--cell-border);
|
||||
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
.dropdown.invertY {
|
||||
transform: translateY(-100%);
|
||||
top: -1px;
|
||||
}
|
||||
.dropdown.invertX {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.searching {
|
||||
padding: var(--cell-padding);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.results {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.result {
|
||||
padding: 0 var(--cell-padding);
|
||||
flex: 0 0 var(--default-row-height);
|
||||
display: flex;
|
||||
gap: var(--cell-spacing);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.result.candidate {
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
cursor: pointer;
|
||||
}
|
||||
.result .badge {
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 0 calc(var(--default-row-height) - 1px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px var(--cell-padding);
|
||||
width: calc(100% - 2 * var(--cell-padding));
|
||||
}
|
||||
.search :global(.spectrum-Textfield) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.search :global(.spectrum-Textfield-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
.search :global(.spectrum-Form-item) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,110 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
export let focused = false
|
||||
export let onChange
|
||||
export let type = "text"
|
||||
export let readonly = false
|
||||
export let api
|
||||
|
||||
let input
|
||||
let active = false
|
||||
|
||||
$: editable = focused && !readonly
|
||||
|
||||
const handleChange = e => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
input?.blur()
|
||||
const event = new KeyboardEvent("keydown", { key: "ArrowDown" })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
focus: () => input?.focus(),
|
||||
blur: () => input?.blur(),
|
||||
onKeyDown,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if editable}
|
||||
<input
|
||||
bind:this={input}
|
||||
on:focus={() => (active = true)}
|
||||
on:blur={() => (active = false)}
|
||||
{type}
|
||||
value={value || ""}
|
||||
on:change={handleChange}
|
||||
spellcheck="false"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-cell" class:number={type === "number"}>
|
||||
<div class="value">
|
||||
{value || ""}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.text-cell {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--cell-padding);
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
.text-cell.number {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.value {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: var(--content-lines);
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 20px;
|
||||
}
|
||||
.number .value {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
border: none;
|
||||
padding: var(--cell-padding);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
font-size: var(--cell-font-size);
|
||||
font-family: var(--font-sans);
|
||||
color: inherit;
|
||||
line-height: 20px;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
input[type="number"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Hide arrows for number fields */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,16 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { config, dispatch } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
icon="TableColumnAddRight"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => dispatch("add-column")}
|
||||
disabled={!$config.allowAddColumns}
|
||||
>
|
||||
Create column
|
||||
</ActionButton>
|
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { dispatch, columns, stickyColumn, config, loaded } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<ActionButton
|
||||
icon="TableRowAddBottom"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => dispatch("add-row")}
|
||||
disabled={!loaded ||
|
||||
!$config.allowAddRows ||
|
||||
(!$columns.length && !$stickyColumn)}
|
||||
>
|
||||
Create row
|
||||
</ActionButton>
|
|
@ -0,0 +1,46 @@
|
|||
<script>
|
||||
import { Button } from "@budibase/bbui"
|
||||
</script>
|
||||
|
||||
<div class="beta-background" />
|
||||
<div class="beta">
|
||||
Enjoying the Grid?
|
||||
<Button
|
||||
size="M"
|
||||
cta
|
||||
on:click={() => window.open("https://t.maze.co/156382627", "_blank")}
|
||||
>
|
||||
Give Feedback
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.beta {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.beta :global(.spectrum-Button) {
|
||||
background: var(--spectrum-global-color-magenta-400);
|
||||
border-color: var(--spectrum-global-color-magenta-400);
|
||||
}
|
||||
.beta-background {
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
bottom: -230px;
|
||||
right: -105px;
|
||||
width: 1400px;
|
||||
height: 320px;
|
||||
transform: rotate(-22deg);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--cell-background) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
<script>
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ActionButton,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { selectedRows, rows, config } = getContext("grid")
|
||||
|
||||
let modal
|
||||
|
||||
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
|
||||
$: rowsToDelete = Object.entries($selectedRows)
|
||||
.map(entry => {
|
||||
if (entry[1] === true) {
|
||||
return $rows.find(x => x._id === entry[0])
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(x => x != null)
|
||||
|
||||
// Deletion callback when confirmed
|
||||
const performDeletion = async () => {
|
||||
const count = rowsToDelete.length
|
||||
await rows.actions.deleteRows(rowsToDelete)
|
||||
notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if selectedRowCount}
|
||||
<div class="delete-button" data-ignore-click-outside="true">
|
||||
<ActionButton
|
||||
icon="Delete"
|
||||
size="S"
|
||||
on:click={modal.show}
|
||||
disabled={!$config.allowEditRows}
|
||||
>
|
||||
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
title="Delete rows"
|
||||
confirmText="Continue"
|
||||
cancelText="Cancel"
|
||||
onConfirm={performDeletion}
|
||||
size="M"
|
||||
>
|
||||
Are you sure you want to delete {selectedRowCount}
|
||||
row{selectedRowCount === 1 ? "" : "s"}?
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.delete-button :global(.spectrum-ActionButton:not(:disabled) *) {
|
||||
color: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
.delete-button :global(.spectrum-ActionButton:not(:disabled)) {
|
||||
border-color: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
/*.delete-button.disabled :global(.spectrum-ActionButton *) {*/
|
||||
/* color: var(--spectrum-global-color-gray-600);*/
|
||||
/*}*/
|
||||
/*.delete-button.disabled :global(.spectrum-ActionButton) {*/
|
||||
/* border-color: var(--spectrum-global-color-gray-400);*/
|
||||
/*}*/
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover, Toggle } from "@budibase/bbui"
|
||||
|
||||
const { columns } = getContext("grid")
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
|
||||
$: anyHidden = $columns.some(col => !col.visible)
|
||||
|
||||
const toggleVisibility = (column, visible) => {
|
||||
columns.update(state => {
|
||||
const index = state.findIndex(col => col.name === column.name)
|
||||
state[index].visible = visible
|
||||
return state.slice()
|
||||
})
|
||||
columns.actions.saveChanges()
|
||||
}
|
||||
|
||||
const showAll = () => {
|
||||
columns.update(state => {
|
||||
return state.map(col => ({
|
||||
...col,
|
||||
visible: true,
|
||||
}))
|
||||
})
|
||||
columns.actions.saveChanges()
|
||||
}
|
||||
|
||||
const hideAll = () => {
|
||||
columns.update(state => {
|
||||
return state.map(col => ({
|
||||
...col,
|
||||
visible: false,
|
||||
}))
|
||||
})
|
||||
columns.actions.saveChanges()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="VisibilityOff"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open || anyHidden}
|
||||
disabled={!$columns.length}
|
||||
>
|
||||
Hide columns
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
<div class="columns">
|
||||
{#each $columns as column}
|
||||
<Toggle
|
||||
size="S"
|
||||
value={column.visible}
|
||||
on:change={e => toggleVisibility(column, e.detail)}
|
||||
/>
|
||||
<span>{column.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<ActionButton on:click={showAll}>Show all</ActionButton>
|
||||
<ActionButton on:click={hideAll}>Hide all</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
padding: 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,70 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover } from "@budibase/bbui"
|
||||
import {
|
||||
LargeRowHeight,
|
||||
MediumRowHeight,
|
||||
SmallRowHeight,
|
||||
} from "../lib/constants"
|
||||
|
||||
const { rowHeight, columns, table } = getContext("grid")
|
||||
const sizeOptions = [
|
||||
{
|
||||
label: "Small",
|
||||
size: SmallRowHeight,
|
||||
},
|
||||
{
|
||||
label: "Medium",
|
||||
size: MediumRowHeight,
|
||||
},
|
||||
{
|
||||
label: "Large",
|
||||
size: LargeRowHeight,
|
||||
},
|
||||
]
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
|
||||
const changeRowHeight = height => {
|
||||
columns.actions.saveTable({
|
||||
...$table,
|
||||
rowHeight: height,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="LineHeight"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open}
|
||||
>
|
||||
Row height
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
{#each sizeOptions as option}
|
||||
<ActionButton
|
||||
quiet
|
||||
selected={$rowHeight === option.size}
|
||||
on:click={() => changeRowHeight(option.size)}
|
||||
>
|
||||
{option.label}
|
||||
</ActionButton>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,107 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { ActionButton, Popover, Select } from "@budibase/bbui"
|
||||
|
||||
const { sort, visibleColumns, stickyColumn } = getContext("grid")
|
||||
const orderOptions = [
|
||||
{ label: "A-Z", value: "ascending" },
|
||||
{ label: "Z-A", value: "descending" },
|
||||
]
|
||||
|
||||
let open = false
|
||||
let anchor
|
||||
|
||||
$: columnOptions = getColumnOptions($stickyColumn, $visibleColumns)
|
||||
$: checkValidSortColumn($sort.column, $stickyColumn, $visibleColumns)
|
||||
|
||||
const getColumnOptions = (stickyColumn, columns) => {
|
||||
let options = []
|
||||
if (stickyColumn) {
|
||||
options.push(stickyColumn.name)
|
||||
}
|
||||
return [...options, ...columns.map(col => col.name)]
|
||||
}
|
||||
|
||||
const updateSortColumn = e => {
|
||||
sort.update(state => ({
|
||||
...state,
|
||||
column: e.detail,
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSortOrder = e => {
|
||||
sort.update(state => ({
|
||||
...state,
|
||||
order: e.detail,
|
||||
}))
|
||||
}
|
||||
|
||||
// Ensure we never have a sort column selected that is not visible
|
||||
const checkValidSortColumn = (sortColumn, stickyColumn, visibleColumns) => {
|
||||
if (!sortColumn) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
sortColumn !== stickyColumn?.name &&
|
||||
!visibleColumns.some(col => col.name === sortColumn)
|
||||
) {
|
||||
if (stickyColumn) {
|
||||
sort.update(state => ({
|
||||
...state,
|
||||
column: stickyColumn.name,
|
||||
}))
|
||||
} else {
|
||||
sort.update(state => ({
|
||||
...state,
|
||||
column: visibleColumns[0]?.name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton
|
||||
icon="SortOrderDown"
|
||||
quiet
|
||||
size="M"
|
||||
on:click={() => (open = !open)}
|
||||
selected={open || $sort.column}
|
||||
disabled={!columnOptions.length}
|
||||
>
|
||||
Sort
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
<Select
|
||||
placeholder={null}
|
||||
value={$sort.column}
|
||||
options={columnOptions}
|
||||
autoWidth
|
||||
on:change={updateSortColumn}
|
||||
label="Column"
|
||||
/>
|
||||
<Select
|
||||
placeholder={null}
|
||||
value={$sort.order}
|
||||
options={orderOptions}
|
||||
autoWidth
|
||||
on:change={updateSortOrder}
|
||||
label="Order"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
padding: 6px 12px 12px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.content :global(.spectrum-Picker) {
|
||||
width: 140px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1 @@
|
|||
export { default as Grid } from "./layout/Grid.svelte"
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
export let user
|
||||
</script>
|
||||
|
||||
<div class="user" style="background:{user.color};" title={user.email}>
|
||||
{user.email[0]}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
div:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,250 @@
|
|||
<script>
|
||||
import { setContext } from "svelte"
|
||||
import { writable } from "svelte/store"
|
||||
import { fade } from "svelte/transition"
|
||||
import { clickOutside, ProgressCircle } from "@budibase/bbui"
|
||||
import { createEventManagers } from "../lib/events"
|
||||
import { createAPIClient } from "../../../api"
|
||||
import { attachStores } from "../stores"
|
||||
import DeleteButton from "../controls/DeleteButton.svelte"
|
||||
import BetaButton from "../controls/BetaButton.svelte"
|
||||
import GridBody from "./GridBody.svelte"
|
||||
import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
|
||||
import ReorderOverlay from "../overlays/ReorderOverlay.svelte"
|
||||
import HeaderRow from "./HeaderRow.svelte"
|
||||
import ScrollOverlay from "../overlays/ScrollOverlay.svelte"
|
||||
import MenuOverlay from "../overlays/MenuOverlay.svelte"
|
||||
import StickyColumn from "./StickyColumn.svelte"
|
||||
import UserAvatars from "./UserAvatars.svelte"
|
||||
import KeyboardManager from "../overlays/KeyboardManager.svelte"
|
||||
import SortButton from "../controls/SortButton.svelte"
|
||||
import AddColumnButton from "../controls/AddColumnButton.svelte"
|
||||
import HideColumnsButton from "../controls/HideColumnsButton.svelte"
|
||||
import AddRowButton from "../controls/AddRowButton.svelte"
|
||||
import RowHeightButton from "../controls/RowHeightButton.svelte"
|
||||
import {
|
||||
MaxCellRenderHeight,
|
||||
MaxCellRenderWidthOverflow,
|
||||
GutterWidth,
|
||||
DefaultRowHeight,
|
||||
} from "../lib/constants"
|
||||
|
||||
export let API = null
|
||||
export let tableId = null
|
||||
export let schemaOverrides = null
|
||||
export let allowAddRows = true
|
||||
export let allowAddColumns = true
|
||||
export let allowEditColumns = true
|
||||
export let allowExpandRows = true
|
||||
export let allowEditRows = true
|
||||
export let allowDeleteRows = true
|
||||
|
||||
// Unique identifier for DOM nodes inside this instance
|
||||
const rand = Math.random()
|
||||
|
||||
// State stores
|
||||
const tableIdStore = writable(tableId)
|
||||
const schemaOverridesStore = writable(schemaOverrides)
|
||||
const config = writable({
|
||||
allowAddRows,
|
||||
allowAddColumns,
|
||||
allowEditColumns,
|
||||
allowExpandRows,
|
||||
allowEditRows,
|
||||
allowDeleteRows,
|
||||
})
|
||||
|
||||
// Build up context
|
||||
let context = {
|
||||
API: API || createAPIClient(),
|
||||
rand,
|
||||
config,
|
||||
tableId: tableIdStore,
|
||||
schemaOverrides: schemaOverridesStore,
|
||||
}
|
||||
context = { ...context, ...createEventManagers() }
|
||||
context = attachStores(context)
|
||||
|
||||
// Reference some stores for local use
|
||||
const {
|
||||
isResizing,
|
||||
isReordering,
|
||||
ui,
|
||||
loaded,
|
||||
loading,
|
||||
rowHeight,
|
||||
contentLines,
|
||||
} = context
|
||||
|
||||
// Keep stores up to date
|
||||
$: tableIdStore.set(tableId)
|
||||
$: schemaOverridesStore.set(schemaOverrides)
|
||||
$: config.set({
|
||||
allowAddRows,
|
||||
allowAddColumns,
|
||||
allowEditColumns,
|
||||
allowExpandRows,
|
||||
allowEditRows,
|
||||
allowDeleteRows,
|
||||
})
|
||||
|
||||
// Set context for children to consume
|
||||
setContext("grid", context)
|
||||
|
||||
// Expose ability to retrieve context externally for external control
|
||||
export const getContext = () => context
|
||||
|
||||
// Initialise websocket for multi-user
|
||||
// onMount(() => createWebsocket(context))
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid"
|
||||
id="grid-{rand}"
|
||||
class:is-resizing={$isResizing}
|
||||
class:is-reordering={$isReordering}
|
||||
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};"
|
||||
>
|
||||
<div class="controls">
|
||||
<div class="controls-left">
|
||||
<AddRowButton />
|
||||
<AddColumnButton />
|
||||
<slot name="controls" />
|
||||
<RowHeightButton />
|
||||
<HideColumnsButton />
|
||||
<SortButton />
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<DeleteButton />
|
||||
<UserAvatars />
|
||||
</div>
|
||||
</div>
|
||||
{#if $loaded}
|
||||
<div class="grid-data-outer" use:clickOutside={ui.actions.blur}>
|
||||
<div class="grid-data-inner">
|
||||
<StickyColumn />
|
||||
<div class="grid-data-content">
|
||||
<HeaderRow />
|
||||
<GridBody />
|
||||
</div>
|
||||
<div class="overlays">
|
||||
<ResizeOverlay />
|
||||
<ReorderOverlay />
|
||||
<BetaButton />
|
||||
<ScrollOverlay />
|
||||
<MenuOverlay />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $loading}
|
||||
<div in:fade|local={{ duration: 130 }} class="grid-loading">
|
||||
<ProgressCircle />
|
||||
</div>
|
||||
{/if}
|
||||
<KeyboardManager />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--cell-background);
|
||||
|
||||
/* Variables */
|
||||
--cell-background: var(--spectrum-global-color-gray-50);
|
||||
--cell-background-hover: var(--spectrum-global-color-gray-100);
|
||||
--cell-padding: 8px;
|
||||
--cell-spacing: 4px;
|
||||
--cell-border: 1px solid var(--spectrum-global-color-gray-200);
|
||||
--cell-font-size: 14px;
|
||||
--controls-height: 50px;
|
||||
}
|
||||
.grid,
|
||||
.grid :global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.grid.is-resizing :global(*) {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
.grid.is-reordering :global(*) {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.grid-data-outer,
|
||||
.grid-data-inner {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-items: flex-start;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grid-data-outer {
|
||||
height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid-data-inner {
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
}
|
||||
.grid-data-content {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
height: var(--controls-height);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
|
||||
padding: var(--cell-padding);
|
||||
gap: var(--cell-spacing);
|
||||
background: var(--background);
|
||||
}
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--cell-spacing);
|
||||
}
|
||||
.controls-right {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Overlays */
|
||||
.overlays {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.grid-loading {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.grid-loading:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import { getContext, onMount } from "svelte"
|
||||
import GridScrollWrapper from "./GridScrollWrapper.svelte"
|
||||
import GridRow from "./GridRow.svelte"
|
||||
|
||||
const { bounds, renderedRows, rowVerticalInversionIndex } = getContext("grid")
|
||||
|
||||
let body
|
||||
|
||||
onMount(() => {
|
||||
// Observe and record the height of the body
|
||||
const observer = new ResizeObserver(() => {
|
||||
bounds.set(body.getBoundingClientRect())
|
||||
})
|
||||
observer.observe(body)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={body} class="grid-body">
|
||||
<GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive>
|
||||
{#each $renderedRows as row, idx}
|
||||
<GridRow {row} {idx} invertY={idx >= $rowVerticalInversionIndex} />
|
||||
{/each}
|
||||
</GridScrollWrapper>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid-body {
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,57 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import DataCell from "../cells/DataCell.svelte"
|
||||
|
||||
export let row
|
||||
export let idx
|
||||
export let invertY = false
|
||||
|
||||
const {
|
||||
focusedCellId,
|
||||
reorder,
|
||||
selectedRows,
|
||||
renderedColumns,
|
||||
hoveredRowId,
|
||||
selectedCellMap,
|
||||
focusedRow,
|
||||
columnHorizontalInversionIndex,
|
||||
} = getContext("grid")
|
||||
|
||||
$: rowSelected = !!$selectedRows[row._id]
|
||||
$: rowHovered = $hoveredRowId === row._id
|
||||
$: rowFocused = $focusedRow?._id === row._id
|
||||
$: reorderSource = $reorder.sourceColumn
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="row"
|
||||
on:focus
|
||||
on:mouseenter={() => ($hoveredRowId = row._id)}
|
||||
on:mouseleave={() => ($hoveredRowId = null)}
|
||||
>
|
||||
{#each $renderedColumns as column, columnIdx (column.name)}
|
||||
{@const cellId = `${row._id}-${column.name}`}
|
||||
<DataCell
|
||||
{cellId}
|
||||
{column}
|
||||
{row}
|
||||
{invertY}
|
||||
{rowFocused}
|
||||
invertX={columnIdx >= $columnHorizontalInversionIndex}
|
||||
highlighted={rowHovered || rowFocused || reorderSource === column.name}
|
||||
selected={rowSelected}
|
||||
rowIdx={idx}
|
||||
focused={$focusedCellId === cellId}
|
||||
selectedUser={$selectedCellMap[cellId]}
|
||||
width={column.width}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
width: 0;
|
||||
display: flex;
|
||||
height: var(--row-height);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,84 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { domDebounce } from "../../../utils/utils"
|
||||
|
||||
const {
|
||||
rowHeight,
|
||||
scroll,
|
||||
focusedCellId,
|
||||
renderedRows,
|
||||
maxScrollTop,
|
||||
maxScrollLeft,
|
||||
bounds,
|
||||
hoveredRowId,
|
||||
hiddenColumnsWidth,
|
||||
} = getContext("grid")
|
||||
|
||||
export let scrollVertically = false
|
||||
export let scrollHorizontally = false
|
||||
export let wheelInteractive = false
|
||||
|
||||
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
|
||||
|
||||
const generateStyle = (scroll, rowHeight, hiddenWidths) => {
|
||||
const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0
|
||||
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
|
||||
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
|
||||
}
|
||||
|
||||
// Handles a wheel even and updates the scroll offsets
|
||||
const handleWheel = e => {
|
||||
e.preventDefault()
|
||||
const modifier = e.ctrlKey || e.metaKey
|
||||
let x = modifier ? e.deltaY : e.deltaX
|
||||
let y = modifier ? e.deltaX : e.deltaY
|
||||
debouncedHandleWheel(x, y, e.clientY)
|
||||
}
|
||||
const debouncedHandleWheel = domDebounce((deltaX, deltaY, clientY) => {
|
||||
const { top, left } = $scroll
|
||||
|
||||
// Calculate new scroll top
|
||||
let newScrollTop = top + deltaY
|
||||
newScrollTop = Math.max(0, Math.min(newScrollTop, $maxScrollTop))
|
||||
|
||||
// Calculate new scroll left
|
||||
let newScrollLeft = left + deltaX
|
||||
newScrollLeft = Math.max(0, Math.min(newScrollLeft, $maxScrollLeft))
|
||||
|
||||
// Update state
|
||||
scroll.set({
|
||||
left: scrollHorizontally ? newScrollLeft : left,
|
||||
top: scrollVertically ? newScrollTop : top,
|
||||
})
|
||||
|
||||
// Hover row under cursor
|
||||
const y = clientY - $bounds.top + (newScrollTop % $rowHeight)
|
||||
const hoveredRow = $renderedRows[Math.floor(y / $rowHeight)]
|
||||
hoveredRowId.set(hoveredRow?._id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="outer"
|
||||
on:wheel={wheelInteractive ? handleWheel : null}
|
||||
on:click|self={() => ($focusedCellId = null)}
|
||||
>
|
||||
<div {style} class="inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outer {
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inner {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,30 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import GridScrollWrapper from "./GridScrollWrapper.svelte"
|
||||
import HeaderCell from "../cells/HeaderCell.svelte"
|
||||
|
||||
const { renderedColumns } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<GridScrollWrapper scrollHorizontally>
|
||||
<div class="row">
|
||||
{#each $renderedColumns as column, idx}
|
||||
<HeaderCell {column} {idx} />
|
||||
{/each}
|
||||
</div>
|
||||
</GridScrollWrapper>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
background: var(--background);
|
||||
border-bottom: var(--cell-border);
|
||||
position: relative;
|
||||
height: var(--default-row-height);
|
||||
z-index: 1;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,247 @@
|
|||
<script>
|
||||
import GridCell from "../cells/GridCell.svelte"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { Icon, Button } from "@budibase/bbui"
|
||||
import GridScrollWrapper from "./GridScrollWrapper.svelte"
|
||||
import DataCell from "../cells/DataCell.svelte"
|
||||
import { fly } from "svelte/transition"
|
||||
import { GutterWidth } from "../lib/constants"
|
||||
|
||||
const {
|
||||
hoveredRowId,
|
||||
focusedCellId,
|
||||
stickyColumn,
|
||||
scroll,
|
||||
config,
|
||||
dispatch,
|
||||
visibleColumns,
|
||||
rows,
|
||||
showHScrollbar,
|
||||
tableId,
|
||||
subscribe,
|
||||
scrollLeft,
|
||||
} = getContext("grid")
|
||||
|
||||
let isAdding = false
|
||||
let newRow = {}
|
||||
let touched = false
|
||||
|
||||
$: firstColumn = $stickyColumn || $visibleColumns[0]
|
||||
$: rowHovered = $hoveredRowId === "new"
|
||||
$: rowFocused = $focusedCellId?.startsWith("new-")
|
||||
$: width = GutterWidth + ($stickyColumn?.width || 0)
|
||||
$: $tableId, (isAdding = false)
|
||||
|
||||
const addRow = async () => {
|
||||
// Create row
|
||||
const savedRow = await rows.actions.addRow(newRow, 0)
|
||||
if (savedRow) {
|
||||
// Select the first cell if possible
|
||||
if (firstColumn) {
|
||||
$focusedCellId = `${savedRow._id}-${firstColumn.name}`
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isAdding = false
|
||||
scroll.set({
|
||||
left: 0,
|
||||
top: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
isAdding = false
|
||||
}
|
||||
|
||||
const startAdding = () => {
|
||||
newRow = {}
|
||||
isAdding = true
|
||||
if (firstColumn) {
|
||||
$focusedCellId = `new-${firstColumn.name}`
|
||||
}
|
||||
}
|
||||
|
||||
const updateValue = (rowId, columnName, val) => {
|
||||
touched = true
|
||||
newRow[columnName] = val
|
||||
}
|
||||
|
||||
const addViaModal = () => {
|
||||
isAdding = false
|
||||
dispatch("add-row")
|
||||
}
|
||||
|
||||
onMount(() => subscribe("add-row-inline", startAdding))
|
||||
</script>
|
||||
|
||||
<!-- Only show new row functionality if we have any columns -->
|
||||
{#if isAdding}
|
||||
<div class="container" transition:fly={{ y: 20, duration: 130 }}>
|
||||
<div class="content" class:above-scrollbar={$showHScrollbar}>
|
||||
<div
|
||||
class="new-row"
|
||||
on:mouseenter={() => ($hoveredRowId = "new")}
|
||||
on:mouseleave={() => ($hoveredRowId = null)}
|
||||
>
|
||||
<div
|
||||
class="sticky-column"
|
||||
style="flex: 0 0 {width}px"
|
||||
class:scrolled={$scrollLeft > 0}
|
||||
>
|
||||
<GridCell width={GutterWidth} {rowHovered} {rowFocused}>
|
||||
<div class="gutter">
|
||||
<div class="number">1</div>
|
||||
{#if $config.allowExpandRows}
|
||||
<Icon
|
||||
name="Maximize"
|
||||
size="S"
|
||||
hoverable
|
||||
on:click={addViaModal}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</GridCell>
|
||||
{#if $stickyColumn}
|
||||
{@const cellId = `new-${$stickyColumn.name}`}
|
||||
<DataCell
|
||||
{cellId}
|
||||
column={$stickyColumn}
|
||||
row={newRow}
|
||||
{rowHovered}
|
||||
focused={$focusedCellId === cellId}
|
||||
{rowFocused}
|
||||
width={$stickyColumn.width}
|
||||
{updateValue}
|
||||
rowIdx={0}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<GridScrollWrapper scrollHorizontally wheelInteractive>
|
||||
<div class="row">
|
||||
{#each $visibleColumns as column}
|
||||
{@const cellId = `new-${column.name}`}
|
||||
{#key cellId}
|
||||
<DataCell
|
||||
{cellId}
|
||||
{column}
|
||||
row={newRow}
|
||||
{rowHovered}
|
||||
focused={$focusedCellId === cellId}
|
||||
{rowFocused}
|
||||
width={column.width}
|
||||
{updateValue}
|
||||
rowIdx={0}
|
||||
/>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</GridScrollWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<Button size="M" cta on:click={addRow}>Save</Button>
|
||||
<Button size="M" secondary newStyles on:click={cancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: var(--row-height);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.container:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--cell-background);
|
||||
opacity: 0.8;
|
||||
z-index: -1;
|
||||
}
|
||||
.content {
|
||||
pointer-events: all;
|
||||
background: var(--background);
|
||||
border-bottom: var(--cell-border);
|
||||
}
|
||||
|
||||
.new-row {
|
||||
display: flex;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
transition: margin-bottom 130ms ease-out;
|
||||
}
|
||||
.new-row :global(.cell) {
|
||||
--cell-background: var(--background) !important;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sticky-column {
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
/* Don't show borders between cells in the sticky column */
|
||||
.sticky-column :global(.cell:not(:last-child)) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Add shadow when scrolled */
|
||||
.sticky-column.scrolled {
|
||||
/*box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.1);*/
|
||||
}
|
||||
.sticky-column.scrolled:after {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0.05), transparent);
|
||||
left: 100%;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Styles for gutter */
|
||||
.gutter {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: var(--cell-padding);
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--cell-spacing);
|
||||
}
|
||||
|
||||
/* Floating buttons */
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
margin: 24px 0 0 var(--gutter-width);
|
||||
pointer-events: all;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.number {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,9 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { rows } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{$rows.length} row{$rows.length === 1 ? "" : "s"}
|
||||
</div>
|
|
@ -0,0 +1,245 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { Checkbox, Icon } from "@budibase/bbui"
|
||||
import GridCell from "../cells/GridCell.svelte"
|
||||
import DataCell from "../cells/DataCell.svelte"
|
||||
import GridScrollWrapper from "./GridScrollWrapper.svelte"
|
||||
import HeaderCell from "../cells/HeaderCell.svelte"
|
||||
import { GutterWidth } from "../lib/constants"
|
||||
|
||||
const {
|
||||
rows,
|
||||
selectedRows,
|
||||
stickyColumn,
|
||||
renderedRows,
|
||||
focusedCellId,
|
||||
hoveredRowId,
|
||||
config,
|
||||
selectedCellMap,
|
||||
focusedRow,
|
||||
dispatch,
|
||||
scrollLeft,
|
||||
} = getContext("grid")
|
||||
|
||||
$: rowCount = $rows.length
|
||||
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
|
||||
$: width = GutterWidth + ($stickyColumn?.width || 0)
|
||||
|
||||
const selectAll = () => {
|
||||
const allSelected = selectedRowCount === rowCount
|
||||
if (allSelected) {
|
||||
$selectedRows = {}
|
||||
} else {
|
||||
let allRows = {}
|
||||
$rows.forEach(row => {
|
||||
allRows[row._id] = true
|
||||
})
|
||||
$selectedRows = allRows
|
||||
}
|
||||
}
|
||||
|
||||
const selectRow = id => {
|
||||
selectedRows.update(state => {
|
||||
let newState = {
|
||||
...state,
|
||||
[id]: !state[id],
|
||||
}
|
||||
if (!newState[id]) {
|
||||
delete newState[id]
|
||||
}
|
||||
return newState
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="sticky-column"
|
||||
style="flex: 0 0 {width}px"
|
||||
class:scrolled={$scrollLeft > 0}
|
||||
>
|
||||
<div class="header row">
|
||||
<GridCell width={GutterWidth} defaultHeight center>
|
||||
<div class="gutter">
|
||||
<div class="checkbox visible">
|
||||
{#if $config.allowDeleteRows}
|
||||
<div on:click={selectAll}>
|
||||
<Checkbox
|
||||
value={rowCount && selectedRowCount === rowCount}
|
||||
disabled={!$renderedRows.length}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.allowExpandRows}
|
||||
<div class="expand">
|
||||
<Icon name="Maximize" size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</GridCell>
|
||||
|
||||
{#if $stickyColumn}
|
||||
<HeaderCell column={$stickyColumn} orderable={false} idx="sticky" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
|
||||
<GridScrollWrapper scrollVertically wheelInteractive>
|
||||
{#each $renderedRows as row, idx}
|
||||
{@const rowSelected = !!$selectedRows[row._id]}
|
||||
{@const rowHovered = $hoveredRowId === row._id}
|
||||
{@const rowFocused = $focusedRow?._id === row._id}
|
||||
{@const cellId = `${row._id}-${$stickyColumn?.name}`}
|
||||
<div
|
||||
class="row"
|
||||
on:mouseenter={() => ($hoveredRowId = row._id)}
|
||||
on:mouseleave={() => ($hoveredRowId = null)}
|
||||
>
|
||||
<GridCell
|
||||
width={GutterWidth}
|
||||
highlighted={rowFocused || rowHovered}
|
||||
selected={rowSelected}
|
||||
>
|
||||
<div class="gutter">
|
||||
<div
|
||||
on:click={() => selectRow(row._id)}
|
||||
class="checkbox"
|
||||
class:visible={$config.allowDeleteRows &&
|
||||
(rowSelected || rowHovered || rowFocused)}
|
||||
>
|
||||
<Checkbox value={rowSelected} />
|
||||
</div>
|
||||
<div
|
||||
class="number"
|
||||
class:visible={!$config.allowDeleteRows ||
|
||||
!(rowSelected || rowHovered || rowFocused)}
|
||||
>
|
||||
{row.__idx + 1}
|
||||
</div>
|
||||
{#if $config.allowExpandRows}
|
||||
<div class="expand" class:visible={rowFocused || rowHovered}>
|
||||
<Icon
|
||||
name="Maximize"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => {
|
||||
dispatch("edit-row", row)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</GridCell>
|
||||
{#if $stickyColumn}
|
||||
<DataCell
|
||||
{row}
|
||||
{cellId}
|
||||
{rowFocused}
|
||||
selected={rowSelected}
|
||||
highlighted={rowHovered || rowFocused}
|
||||
rowIdx={idx}
|
||||
focused={$focusedCellId === cellId}
|
||||
selectedUser={$selectedCellMap[cellId]}
|
||||
width={$stickyColumn.width}
|
||||
column={$stickyColumn}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</GridScrollWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sticky-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Add right border */
|
||||
.sticky-column:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 100%;
|
||||
left: calc(100% - 1px);
|
||||
top: 0;
|
||||
border-left: var(--cell-border);
|
||||
}
|
||||
|
||||
/* Add shadow when scrolled */
|
||||
.sticky-column.scrolled:after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
background: linear-gradient(to right, var(--drop-shadow), transparent);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Don't show borders between cells in the sticky column */
|
||||
.sticky-column :global(.cell:not(:last-child)) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
z-index: 1;
|
||||
}
|
||||
.header :global(.cell) {
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Styles for gutter */
|
||||
.gutter {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: var(--cell-padding);
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--cell-spacing);
|
||||
}
|
||||
.checkbox,
|
||||
.number {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.checkbox :global(.spectrum-Checkbox) {
|
||||
min-height: 0;
|
||||
height: 20px;
|
||||
}
|
||||
.checkbox :global(.spectrum-Checkbox-box) {
|
||||
margin: 3px 0 0 0;
|
||||
}
|
||||
.number {
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
.checkbox.visible,
|
||||
.number.visible {
|
||||
display: flex;
|
||||
}
|
||||
.expand {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.expand.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,20 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import Avatar from "./Avatar.svelte"
|
||||
|
||||
const { users } = getContext("grid")
|
||||
</script>
|
||||
|
||||
<div class="users">
|
||||
{#each $users as user}
|
||||
<Avatar {user} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.users {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,11 @@
|
|||
export const Padding = 276
|
||||
export const MaxCellRenderHeight = 252
|
||||
export const MaxCellRenderWidthOverflow = 200
|
||||
export const ScrollBarSize = 8
|
||||
export const GutterWidth = 72
|
||||
export const DefaultColumnWidth = 200
|
||||
export const MinColumnWidth = 100
|
||||
export const SmallRowHeight = 36
|
||||
export const MediumRowHeight = 64
|
||||
export const LargeRowHeight = 92
|
||||
export const DefaultRowHeight = SmallRowHeight
|
|
@ -0,0 +1,29 @@
|
|||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export const createEventManagers = () => {
|
||||
const svelteDispatch = createEventDispatcher()
|
||||
let subscribers = {}
|
||||
|
||||
// Dispatches an event, notifying subscribers and also emitting a normal
|
||||
// svelte event
|
||||
const dispatch = (event, payload) => {
|
||||
svelteDispatch(event, payload)
|
||||
const subs = subscribers[event] || []
|
||||
for (let i = 0; i < subs.length; i++) {
|
||||
subs[i](payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribes to events
|
||||
const subscribe = (event, callback) => {
|
||||
const subs = subscribers[event] || []
|
||||
subscribers[event] = [...subs, callback]
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
subscribers[event] = subscribers[event].filter(cb => cb !== callback)
|
||||
}
|
||||
}
|
||||
|
||||
return { dispatch, subscribe }
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import OptionsCell from "../cells/OptionsCell.svelte"
|
||||
import DateCell from "../cells/DateCell.svelte"
|
||||
import MultiSelectCell from "../cells/MultiSelectCell.svelte"
|
||||
import NumberCell from "../cells/NumberCell.svelte"
|
||||
import RelationshipCell from "../cells/RelationshipCell.svelte"
|
||||
import TextCell from "../cells/TextCell.svelte"
|
||||
import LongFormCell from "../cells/LongFormCell.svelte"
|
||||
import BooleanCell from "../cells/BooleanCell.svelte"
|
||||
import FormulaCell from "../cells/FormulaCell.svelte"
|
||||
import JSONCell from "../cells/JSONCell.svelte"
|
||||
import AttachmentCell from "../cells/AttachmentCell.svelte"
|
||||
|
||||
const TypeComponentMap = {
|
||||
text: TextCell,
|
||||
options: OptionsCell,
|
||||
datetime: DateCell,
|
||||
barcodeqr: TextCell,
|
||||
longform: LongFormCell,
|
||||
array: MultiSelectCell,
|
||||
number: NumberCell,
|
||||
boolean: BooleanCell,
|
||||
attachment: AttachmentCell,
|
||||
link: RelationshipCell,
|
||||
formula: FormulaCell,
|
||||
json: JSONCell,
|
||||
}
|
||||
export const getCellRenderer = column => {
|
||||
return TypeComponentMap[column?.schema?.type] || TextCell
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
export const getColor = (idx, opacity = 0.3) => {
|
||||
if (idx == null || idx === -1) {
|
||||
return null
|
||||
}
|
||||
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})`
|
||||
}
|
||||
|
||||
const TypeIconMap = {
|
||||
text: "Text",
|
||||
options: "Dropdown",
|
||||
datetime: "Date",
|
||||
barcodeqr: "Camera",
|
||||
longform: "TextAlignLeft",
|
||||
array: "Dropdown",
|
||||
number: "123",
|
||||
boolean: "Boolean",
|
||||
attachment: "AppleFiles",
|
||||
link: "Link",
|
||||
formula: "Calculator",
|
||||
json: "Brackets",
|
||||
}
|
||||
|
||||
export const getColumnIcon = column => {
|
||||
const type = column.schema.type
|
||||
return TypeIconMap[type] || "Text"
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { get } from "svelte/store"
|
||||
import { io } from "socket.io-client"
|
||||
|
||||
export const createWebsocket = context => {
|
||||
const { rows, tableId, users, userId, focusedCellId } = context
|
||||
|
||||
// Determine connection info
|
||||
const tls = location.protocol === "https:"
|
||||
const proto = tls ? "wss:" : "ws:"
|
||||
const host = location.hostname
|
||||
const port = location.port || (tls ? 443 : 80)
|
||||
const socket = io(`${proto}//${host}:${port}`, {
|
||||
path: "/socket/grid",
|
||||
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
|
||||
reconnectionAttempts: 3,
|
||||
// Delay reconnection attempt by 5 seconds
|
||||
reconnectionDelay: 5000,
|
||||
reconnectionDelayMax: 5000,
|
||||
// Timeout after 4 seconds so we never stack requests
|
||||
timeout: 4000,
|
||||
})
|
||||
|
||||
const connectToTable = tableId => {
|
||||
if (!socket.connected) {
|
||||
return
|
||||
}
|
||||
// Identify which table we are editing
|
||||
socket.emit("select-table", tableId, response => {
|
||||
// handle initial connection info
|
||||
users.set(response.users)
|
||||
userId.set(response.id)
|
||||
})
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
socket.on("connect", () => {
|
||||
connectToTable(get(tableId))
|
||||
})
|
||||
socket.on("row-update", data => {
|
||||
if (data.id) {
|
||||
rows.actions.refreshRow(data.id)
|
||||
}
|
||||
})
|
||||
socket.on("user-update", user => {
|
||||
users.actions.updateUser(user)
|
||||
})
|
||||
socket.on("user-disconnect", user => {
|
||||
users.actions.removeUser(user)
|
||||
})
|
||||
socket.on("connect_error", err => {
|
||||
console.log("Failed to connect to grid websocket:", err.message)
|
||||
})
|
||||
|
||||
// Change websocket connection when table changes
|
||||
tableId.subscribe(connectToTable)
|
||||
|
||||
// Notify selected cell changes
|
||||
focusedCellId.subscribe($focusedCellId => {
|
||||
socket.emit("select-cell", $focusedCellId)
|
||||
})
|
||||
|
||||
return () => socket?.disconnect()
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
<script>
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { debounce } from "../../../utils/utils"
|
||||
|
||||
const {
|
||||
enrichedRows,
|
||||
focusedCellId,
|
||||
visibleColumns,
|
||||
focusedRow,
|
||||
stickyColumn,
|
||||
focusedCellAPI,
|
||||
clipboard,
|
||||
} = getContext("grid")
|
||||
|
||||
// Global key listener which intercepts all key events
|
||||
const handleKeyDown = e => {
|
||||
// If nothing selected avoid processing further key presses
|
||||
if (!$focusedCellId) {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
focusFirstCell()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Always intercept certain key presses
|
||||
const api = $focusedCellAPI
|
||||
if (e.key === "Escape") {
|
||||
api?.blur?.()
|
||||
} else if (e.key === "Tab") {
|
||||
api?.blur?.()
|
||||
changeFocusedColumn(1)
|
||||
}
|
||||
|
||||
// Pass the key event to the selected cell and let it decide whether to
|
||||
// capture it or not
|
||||
if (!api?.isReadonly()) {
|
||||
const handled = api?.onKeyDown?.(e)
|
||||
if (handled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid processing events sourced from modals
|
||||
if (e.target?.closest?.(".spectrum-Modal")) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
// Handle the key ourselves
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
switch (e.key) {
|
||||
case "c":
|
||||
clipboard.actions.copy()
|
||||
break
|
||||
case "v":
|
||||
clipboard.actions.paste()
|
||||
break
|
||||
}
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
changeFocusedColumn(-1)
|
||||
break
|
||||
case "ArrowRight":
|
||||
changeFocusedColumn(1)
|
||||
break
|
||||
case "ArrowUp":
|
||||
changeFocusedRow(-1)
|
||||
break
|
||||
case "ArrowDown":
|
||||
changeFocusedRow(1)
|
||||
break
|
||||
case "Delete":
|
||||
case "Backspace":
|
||||
deleteSelectedCell()
|
||||
break
|
||||
case "Enter":
|
||||
focusCell()
|
||||
break
|
||||
default:
|
||||
startEnteringValue(e.key, e.which)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focuses the first cell in the grid
|
||||
const focusFirstCell = () => {
|
||||
const firstRow = $enrichedRows[0]
|
||||
if (!firstRow) {
|
||||
return
|
||||
}
|
||||
const firstColumn = $stickyColumn || $visibleColumns[0]
|
||||
if (!firstColumn) {
|
||||
return
|
||||
}
|
||||
focusedCellId.set(`${firstRow._id}-${firstColumn.name}`)
|
||||
}
|
||||
|
||||
// Changes the focused cell by moving it left or right to a different column
|
||||
const changeFocusedColumn = delta => {
|
||||
if (!$focusedCellId) {
|
||||
return
|
||||
}
|
||||
const cols = $visibleColumns
|
||||
const split = $focusedCellId.split("-")
|
||||
const columnName = split[1]
|
||||
let newColumnName
|
||||
if (columnName === $stickyColumn?.name) {
|
||||
const index = delta - 1
|
||||
newColumnName = cols[index]?.name
|
||||
} else {
|
||||
const index = cols.findIndex(col => col.name === columnName) + delta
|
||||
if (index === -1) {
|
||||
newColumnName = $stickyColumn?.name
|
||||
} else {
|
||||
newColumnName = cols[index]?.name
|
||||
}
|
||||
}
|
||||
if (newColumnName) {
|
||||
$focusedCellId = `${split[0]}-${newColumnName}`
|
||||
}
|
||||
}
|
||||
|
||||
// Changes the focused cell by moving it up or down to a new row
|
||||
const changeFocusedRow = delta => {
|
||||
if (!$focusedRow) {
|
||||
return
|
||||
}
|
||||
const newRow = $enrichedRows[$focusedRow.__idx + delta]
|
||||
if (newRow) {
|
||||
const split = $focusedCellId.split("-")
|
||||
$focusedCellId = `${newRow._id}-${split[1]}`
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce to avoid holding down delete and spamming requests
|
||||
const deleteSelectedCell = debounce(() => {
|
||||
if ($focusedCellAPI?.isReadonly()) {
|
||||
return
|
||||
}
|
||||
$focusedCellAPI.setValue(null)
|
||||
}, 100)
|
||||
|
||||
// Focuses the current cell for editing
|
||||
const focusCell = () => {
|
||||
if ($focusedCellAPI?.isReadonly()) {
|
||||
return
|
||||
}
|
||||
$focusedCellAPI?.focus?.()
|
||||
}
|
||||
|
||||
// Utils to identify a key code
|
||||
const keyCodeIsNumber = keyCode => keyCode >= 48 && keyCode <= 57
|
||||
const keyCodeIsLetter = keyCode => keyCode >= 65 && keyCode <= 90
|
||||
|
||||
// Focuses the cell and starts entering a new value
|
||||
const startEnteringValue = (key, keyCode) => {
|
||||
if ($focusedCellAPI) {
|
||||
const type = $focusedCellAPI.getType()
|
||||
if (type === "number" && keyCodeIsNumber(keyCode)) {
|
||||
$focusedCellAPI.setValue(parseInt(key))
|
||||
$focusedCellAPI.focus()
|
||||
} else if (
|
||||
["string", "barcodeqr", "longform"].includes(type) &&
|
||||
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
|
||||
) {
|
||||
$focusedCellAPI.setValue(key)
|
||||
$focusedCellAPI.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -0,0 +1,95 @@
|
|||
<script>
|
||||
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const {
|
||||
focusedRow,
|
||||
menu,
|
||||
rows,
|
||||
columns,
|
||||
focusedCellId,
|
||||
stickyColumn,
|
||||
config,
|
||||
copiedCell,
|
||||
clipboard,
|
||||
dispatch,
|
||||
} = getContext("grid")
|
||||
|
||||
$: style = makeStyle($menu)
|
||||
|
||||
const makeStyle = menu => {
|
||||
return `left:${menu.left}px; top:${menu.top}px;`
|
||||
}
|
||||
|
||||
const deleteRow = () => {
|
||||
rows.actions.deleteRows([$focusedRow])
|
||||
menu.actions.close()
|
||||
notifications.success("Deleted 1 row")
|
||||
}
|
||||
|
||||
const duplicate = async () => {
|
||||
menu.actions.close()
|
||||
const newRow = await rows.actions.duplicateRow($focusedRow)
|
||||
if (newRow) {
|
||||
const column = $stickyColumn?.name || $columns[0].name
|
||||
$focusedCellId = `${newRow._id}-${column}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $menu.visible}
|
||||
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}>
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
on:click={clipboard.actions.copy}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Copy
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Paste"
|
||||
disabled={$copiedCell == null}
|
||||
on:click={clipboard.actions.paste}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Paste
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Maximize"
|
||||
disabled={!$config.allowEditRows}
|
||||
on:click={() => dispatch("edit-row", $focusedRow)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Edit row in modal
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Duplicate"
|
||||
disabled={!$config.allowAddRows}
|
||||
on:click={duplicate}
|
||||
>
|
||||
Duplicate row
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Delete"
|
||||
disabled={!$config.allowDeleteRows}
|
||||
on:click={deleteRow}
|
||||
>
|
||||
Delete row
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
position: absolute;
|
||||
background: var(--cell-background);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
width: 180px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,63 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import GridScrollWrapper from "../layout/GridScrollWrapper.svelte"
|
||||
import { DefaultRowHeight, GutterWidth } from "../lib/constants"
|
||||
|
||||
const {
|
||||
isReordering,
|
||||
reorder,
|
||||
visibleColumns,
|
||||
stickyColumn,
|
||||
rowHeight,
|
||||
renderedRows,
|
||||
scrollLeft,
|
||||
} = getContext("grid")
|
||||
|
||||
$: targetColumn = $reorder.targetColumn
|
||||
$: minLeft = GutterWidth + ($stickyColumn?.width || 0)
|
||||
$: left = getLeft(targetColumn, $stickyColumn, $visibleColumns, $scrollLeft)
|
||||
$: height = $rowHeight * $renderedRows.length + DefaultRowHeight
|
||||
$: style = `left:${left}px; height:${height}px;`
|
||||
$: visible = $isReordering && left >= minLeft
|
||||
|
||||
const getLeft = (targetColumn, stickyColumn, visibleColumns, scrollLeft) => {
|
||||
let left = GutterWidth + (stickyColumn?.width || 0) - scrollLeft
|
||||
|
||||
// If this is not the sticky column, add additional left space
|
||||
if (targetColumn !== stickyColumn?.name) {
|
||||
const column = visibleColumns.find(x => x.name === targetColumn)
|
||||
if (!column) {
|
||||
return left
|
||||
}
|
||||
left += column.left + column.width
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="reorder-wrapper">
|
||||
<GridScrollWrapper scrollVertically>
|
||||
<div class="reorder-overlay" {style} />
|
||||
</GridScrollWrapper>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.reorder-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reorder-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
background: var(--spectrum-global-color-blue-400);
|
||||
margin-left: -2px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,70 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { GutterWidth } from "../lib/constants"
|
||||
|
||||
const {
|
||||
columns,
|
||||
resize,
|
||||
renderedColumns,
|
||||
stickyColumn,
|
||||
isReordering,
|
||||
scrollLeft,
|
||||
} = getContext("grid")
|
||||
|
||||
$: cutoff = $scrollLeft + GutterWidth + ($columns[0]?.width || 0)
|
||||
$: offset = GutterWidth + ($stickyColumn?.width || 0)
|
||||
$: activeColumn = $resize.column
|
||||
|
||||
const getStyle = (column, offset, scrollLeft) => {
|
||||
const left = offset + column.left + column.width - scrollLeft
|
||||
return `left:${left}px;`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !$isReordering}
|
||||
{#if $stickyColumn}
|
||||
<div
|
||||
class="resize-slider"
|
||||
class:visible={activeColumn === $stickyColumn.name}
|
||||
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
|
||||
on:dblclick={() => resize.actions.resetSize($stickyColumn)}
|
||||
style="left:{GutterWidth + $stickyColumn.width}px;"
|
||||
>
|
||||
<div class="resize-indicator" />
|
||||
</div>
|
||||
{/if}
|
||||
{#each $renderedColumns as column}
|
||||
<div
|
||||
class="resize-slider"
|
||||
class:visible={activeColumn === column.name}
|
||||
on:mousedown={e => resize.actions.startResizing(column, e)}
|
||||
on:dblclick={() => resize.actions.resetSize(column)}
|
||||
style={getStyle(column, offset, $scrollLeft)}
|
||||
>
|
||||
<div class="resize-indicator" />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.resize-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: var(--default-row-height);
|
||||
opacity: 0;
|
||||
padding: 0 8px;
|
||||
transform: translateX(-50%);
|
||||
user-select: none;
|
||||
}
|
||||
.resize-slider:hover,
|
||||
.resize-slider.visible {
|
||||
cursor: col-resize;
|
||||
opacity: 1;
|
||||
}
|
||||
.resize-indicator {
|
||||
margin-left: -1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: var(--spectrum-global-color-blue-400);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,121 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { domDebounce } from "../../../utils/utils"
|
||||
import { DefaultRowHeight, ScrollBarSize } from "../lib/constants"
|
||||
|
||||
const {
|
||||
scroll,
|
||||
contentHeight,
|
||||
maxScrollTop,
|
||||
contentWidth,
|
||||
maxScrollLeft,
|
||||
screenWidth,
|
||||
showHScrollbar,
|
||||
showVScrollbar,
|
||||
scrollLeft,
|
||||
scrollTop,
|
||||
height,
|
||||
} = getContext("grid")
|
||||
|
||||
// State for dragging bars
|
||||
let initialMouse
|
||||
let initialScroll
|
||||
|
||||
// Calculate V scrollbar size and offset
|
||||
// Terminology is the same for both axes:
|
||||
// renderX - the space available to render the bar in, edge to edge
|
||||
// availX - the space available to render the bar in, until the edge
|
||||
$: renderHeight = $height - 2 * ScrollBarSize
|
||||
$: barHeight = Math.max(50, ($height / $contentHeight) * renderHeight)
|
||||
$: availHeight = renderHeight - barHeight
|
||||
$: barTop =
|
||||
ScrollBarSize +
|
||||
DefaultRowHeight +
|
||||
availHeight * ($scrollTop / $maxScrollTop)
|
||||
|
||||
// Calculate H scrollbar size and offset
|
||||
$: renderWidth = $screenWidth - 2 * ScrollBarSize
|
||||
$: barWidth = Math.max(50, ($screenWidth / $contentWidth) * renderWidth)
|
||||
$: availWidth = renderWidth - barWidth
|
||||
$: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft)
|
||||
|
||||
// V scrollbar drag handlers
|
||||
const startVDragging = e => {
|
||||
e.preventDefault()
|
||||
initialMouse = e.clientY
|
||||
initialScroll = $scrollTop
|
||||
document.addEventListener("mousemove", moveVDragging)
|
||||
document.addEventListener("mouseup", stopVDragging)
|
||||
}
|
||||
const moveVDragging = domDebounce(e => {
|
||||
const delta = e.clientY - initialMouse
|
||||
const weight = delta / availHeight
|
||||
const newScrollTop = initialScroll + weight * $maxScrollTop
|
||||
scroll.update(state => ({
|
||||
...state,
|
||||
top: Math.max(0, Math.min(newScrollTop, $maxScrollTop)),
|
||||
}))
|
||||
})
|
||||
const stopVDragging = () => {
|
||||
document.removeEventListener("mousemove", moveVDragging)
|
||||
document.removeEventListener("mouseup", stopVDragging)
|
||||
}
|
||||
|
||||
// H scrollbar drag handlers
|
||||
const startHDragging = e => {
|
||||
e.preventDefault()
|
||||
initialMouse = e.clientX
|
||||
initialScroll = $scrollLeft
|
||||
document.addEventListener("mousemove", moveHDragging)
|
||||
document.addEventListener("mouseup", stopHDragging)
|
||||
}
|
||||
const moveHDragging = domDebounce(e => {
|
||||
const delta = e.clientX - initialMouse
|
||||
const weight = delta / availWidth
|
||||
const newScrollLeft = initialScroll + weight * $maxScrollLeft
|
||||
scroll.update(state => ({
|
||||
...state,
|
||||
left: Math.max(0, Math.min(newScrollLeft, $maxScrollLeft)),
|
||||
}))
|
||||
})
|
||||
const stopHDragging = () => {
|
||||
document.removeEventListener("mousemove", moveHDragging)
|
||||
document.removeEventListener("mouseup", stopHDragging)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $showVScrollbar}
|
||||
<div
|
||||
class="v-scrollbar"
|
||||
style="--size:{ScrollBarSize}px; top:{barTop}px; height:{barHeight}px;"
|
||||
on:mousedown={startVDragging}
|
||||
/>
|
||||
{/if}
|
||||
{#if $showHScrollbar}
|
||||
<div
|
||||
class="h-scrollbar"
|
||||
style="--size:{ScrollBarSize}px; left:{barLeft}px; width:{barWidth}px;"
|
||||
on:mousedown={startHDragging}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
div {
|
||||
position: absolute;
|
||||
background: var(--spectrum-global-color-gray-500);
|
||||
opacity: 0.7;
|
||||
border-radius: 4px;
|
||||
transition: opacity 130ms ease-out;
|
||||
}
|
||||
div:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.v-scrollbar {
|
||||
width: var(--size);
|
||||
right: var(--size);
|
||||
}
|
||||
.h-scrollbar {
|
||||
height: var(--size);
|
||||
bottom: var(--size);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,16 @@
|
|||
import { derived, writable } from "svelte/store"
|
||||
|
||||
export const createStores = () => {
|
||||
const bounds = writable({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
// Derive height and width as primitives to avoid wasted computation
|
||||
const width = derived(bounds, $bounds => $bounds.width, 0)
|
||||
const height = derived(bounds, $bounds => $bounds.height, 0)
|
||||
|
||||
return { bounds, height, width }
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { writable, get } from "svelte/store"
|
||||
|
||||
export const createStores = () => {
|
||||
const copiedCell = writable(null)
|
||||
|
||||
return {
|
||||
copiedCell,
|
||||
}
|
||||
}
|
||||
|
||||
export const deriveStores = context => {
|
||||
const { copiedCell, focusedCellAPI } = context
|
||||
|
||||
const copy = () => {
|
||||
copiedCell.set(get(focusedCellAPI)?.getValue())
|
||||
}
|
||||
|
||||
const paste = () => {
|
||||
const $copiedCell = get(copiedCell)
|
||||
const $focusedCellAPI = get(focusedCellAPI)
|
||||
if ($copiedCell != null && $focusedCellAPI) {
|
||||
$focusedCellAPI.setValue($copiedCell)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clipboard: {
|
||||
actions: {
|
||||
copy,
|
||||
paste,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
import { derived, get, writable } from "svelte/store"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { GutterWidth, DefaultColumnWidth } from "../lib/constants"
|
||||
|
||||
export const createStores = () => {
|
||||
const columns = writable([])
|
||||
const stickyColumn = writable(null)
|
||||
|
||||
// Derive an enriched version of columns with left offsets and indexes
|
||||
// automatically calculated
|
||||
const enrichedColumns = derived(
|
||||
columns,
|
||||
$columns => {
|
||||
let offset = 0
|
||||
return $columns.map(column => {
|
||||
const enriched = {
|
||||
...column,
|
||||
left: offset,
|
||||
}
|
||||
if (column.visible) {
|
||||
offset += column.width
|
||||
}
|
||||
return enriched
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Derived list of columns which have not been explicitly hidden
|
||||
const visibleColumns = derived(
|
||||
enrichedColumns,
|
||||
$columns => {
|
||||
return $columns.filter(col => col.visible)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
columns: {
|
||||
...columns,
|
||||
subscribe: enrichedColumns.subscribe,
|
||||
},
|
||||
stickyColumn,
|
||||
visibleColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export const deriveStores = context => {
|
||||
const { table, columns, stickyColumn, API, dispatch } = context
|
||||
|
||||
// Updates the tables primary display column
|
||||
const changePrimaryDisplay = async column => {
|
||||
return await saveTable({
|
||||
...get(table),
|
||||
primaryDisplay: column,
|
||||
})
|
||||
}
|
||||
|
||||
// Persists column changes by saving metadata against table schema
|
||||
const saveChanges = async () => {
|
||||
const $columns = get(columns)
|
||||
const $table = get(table)
|
||||
const $stickyColumn = get(stickyColumn)
|
||||
const newSchema = cloneDeep($table.schema)
|
||||
|
||||
// Build new updated table schema
|
||||
Object.keys(newSchema).forEach(column => {
|
||||
// Respect order specified by columns
|
||||
const index = $columns.findIndex(x => x.name === column)
|
||||
if (index !== -1) {
|
||||
newSchema[column].order = index
|
||||
} else {
|
||||
delete newSchema[column].order
|
||||
}
|
||||
|
||||
// Copy over metadata
|
||||
if (column === $stickyColumn?.name) {
|
||||
newSchema[column].visible = true
|
||||
newSchema[column].width = $stickyColumn.width || DefaultColumnWidth
|
||||
} else {
|
||||
newSchema[column].visible = $columns[index]?.visible ?? true
|
||||
newSchema[column].width = $columns[index]?.width || DefaultColumnWidth
|
||||
}
|
||||
})
|
||||
|
||||
await saveTable({ ...$table, schema: newSchema })
|
||||
}
|
||||
|
||||
const saveTable = async newTable => {
|
||||
// Update local state
|
||||
table.set(newTable)
|
||||
|
||||
// Broadcast event so that we can keep sync with external state
|
||||
// (e.g. data section which maintains a list of table definitions)
|
||||
dispatch("updatetable", newTable)
|
||||
|
||||
// Update server
|
||||
await API.saveTable(newTable)
|
||||
}
|
||||
|
||||
return {
|
||||
columns: {
|
||||
...columns,
|
||||
actions: {
|
||||
saveChanges,
|
||||
saveTable,
|
||||
changePrimaryDisplay,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const initialise = context => {
|
||||
const { table, columns, stickyColumn, schemaOverrides } = context
|
||||
|
||||
const schema = derived(
|
||||
[table, schemaOverrides],
|
||||
([$table, $schemaOverrides]) => {
|
||||
let newSchema = $table?.schema
|
||||
if (!newSchema) {
|
||||
return null
|
||||
}
|
||||
Object.keys($schemaOverrides || {}).forEach(field => {
|
||||
if (newSchema[field]) {
|
||||
newSchema[field] = {
|
||||
...newSchema[field],
|
||||
...$schemaOverrides[field],
|
||||
}
|
||||
}
|
||||
})
|
||||
return newSchema
|
||||
}
|
||||
)
|
||||
|
||||
// Merge new schema fields with existing schema in order to preserve widths
|
||||
schema.subscribe($schema => {
|
||||
if (!$schema) {
|
||||
columns.set([])
|
||||
stickyColumn.set(null)
|
||||
return
|
||||
}
|
||||
const $table = get(table)
|
||||
|
||||
// Find primary display
|
||||
let primaryDisplay
|
||||
if ($table.primaryDisplay && $schema[$table.primaryDisplay]) {
|
||||
primaryDisplay = $table.primaryDisplay
|
||||
}
|
||||
|
||||
// Get field list
|
||||
let fields = []
|
||||
Object.keys($schema).forEach(field => {
|
||||
if (field !== primaryDisplay) {
|
||||
fields.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
// Update columns, removing extraneous columns and adding missing ones
|
||||
columns.set(
|
||||
fields
|
||||
.map(field => ({
|
||||
name: field,
|
||||
label: $schema[field].name || field,
|
||||
schema: $schema[field],
|
||||
width: $schema[field].width || DefaultColumnWidth,
|
||||
visible: $schema[field].visible ?? true,
|
||||
order: $schema[field].order,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Sort by order first
|
||||
const orderA = a.order
|
||||
const orderB = b.order
|
||||
if (orderA != null && orderB != null) {
|
||||
return orderA < orderB ? -1 : 1
|
||||
} else if (orderA != null) {
|
||||
return -1
|
||||
} else if (orderB != null) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Then sort by auto columns
|
||||
const autoColA = a.schema?.autocolumn
|
||||
const autoColB = b.schema?.autocolumn
|
||||
if (autoColA === autoColB) {
|
||||
return 0
|
||||
}
|
||||
return autoColA ? 1 : -1
|
||||
})
|
||||
)
|
||||
|
||||
// Update sticky column
|
||||
if (!primaryDisplay) {
|
||||
stickyColumn.set(null)
|
||||
return
|
||||
}
|
||||
stickyColumn.set({
|
||||
name: primaryDisplay,
|
||||
label: $schema[primaryDisplay].name || primaryDisplay,
|
||||
schema: $schema[primaryDisplay],
|
||||
width: $schema[primaryDisplay].width || DefaultColumnWidth,
|
||||
visible: true,
|
||||
order: 0,
|
||||
left: GutterWidth,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import * as Bounds from "./bounds"
|
||||
import * as Columns from "./columns"
|
||||
import * as Menu from "./menu"
|
||||
import * as Pagination from "./pagination"
|
||||
import * as Reorder from "./reorder"
|
||||
import * as Resize from "./resize"
|
||||
import * as Rows from "./rows"
|
||||
import * as Scroll from "./scroll"
|
||||
import * as UI from "./ui"
|
||||
import * as Users from "./users"
|
||||
import * as Validation from "./validation"
|
||||
import * as Viewport from "./viewport"
|
||||
import * as Clipboard from "./clipboard"
|
||||
|
||||
const DependencyOrderedStores = [
|
||||
Bounds,
|
||||
Scroll,
|
||||
Rows,
|
||||
Columns,
|
||||
UI,
|
||||
Validation,
|
||||
Resize,
|
||||
Viewport,
|
||||
Reorder,
|
||||
Users,
|
||||
Menu,
|
||||
Pagination,
|
||||
Clipboard,
|
||||
]
|
||||
|
||||
export const attachStores = context => {
|
||||
// Atomic store creation
|
||||
for (let store of DependencyOrderedStores) {
|
||||
context = { ...context, ...store.createStores?.(context) }
|
||||
}
|
||||
|
||||
// Derived store creation
|
||||
for (let store of DependencyOrderedStores) {
|
||||
context = { ...context, ...store.deriveStores?.(context) }
|
||||
}
|
||||
|
||||
// Initialise any store logic
|
||||
for (let store of DependencyOrderedStores) {
|
||||
store.initialise?.(context)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { writable, get } from "svelte/store"
|
||||
import { GutterWidth } from "../lib/constants"
|
||||
|
||||
export const createStores = () => {
|
||||
const menu = writable({
|
||||
x: 0,
|
||||
y: 0,
|
||||
visible: false,
|
||||
selectedRow: null,
|
||||
})
|
||||
return {
|
||||
menu,
|
||||
}
|
||||
}
|
||||
|
||||
export const deriveStores = context => {
|
||||
const { menu, bounds, focusedCellId, stickyColumn, rowHeight } = context
|
||||
|
||||
const open = (cellId, e) => {
|
||||
const $bounds = get(bounds)
|
||||
const $stickyColumn = get(stickyColumn)
|
||||
const $rowHeight = get(rowHeight)
|
||||
e.preventDefault()
|
||||
focusedCellId.set(cellId)
|
||||
menu.set({
|
||||
left:
|
||||
e.clientX - $bounds.left + GutterWidth + ($stickyColumn?.width || 0),
|
||||
top: e.clientY - $bounds.top + $rowHeight,
|
||||
visible: true,
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
menu.update(state => ({
|
||||
...state,
|
||||
visible: false,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
menu: {
|
||||
...menu,
|
||||
actions: {
|
||||
open,
|
||||
close,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { derived } from "svelte/store"
|
||||
|
||||
export const initialise = context => {
|
||||
const { scrolledRowCount, rows, visualRowCapacity } = context
|
||||
|
||||
// Derive how many rows we have in total
|
||||
const rowCount = derived(rows, $rows => $rows.length, 0)
|
||||
|
||||
// Derive how many rows we have available to scroll
|
||||
const remainingRows = derived(
|
||||
[scrolledRowCount, rowCount, visualRowCapacity],
|
||||
([$scrolledRowCount, $rowCount, $visualRowCapacity]) => {
|
||||
return Math.max(0, $rowCount - $scrolledRowCount - $visualRowCapacity)
|
||||
},
|
||||
100
|
||||
)
|
||||
|
||||
// Fetch next page when fewer than 25 remaining rows to scroll
|
||||
remainingRows.subscribe(remaining => {
|
||||
if (remaining < 25) {
|
||||
rows.actions.loadNextPage()
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import { get, writable, derived } from "svelte/store"
|
||||
|
||||
const reorderInitialState = {
|
||||
sourceColumn: null,
|
||||
targetColumn: null,
|
||||
breakpoints: [],
|
||||
initialMouseX: null,
|
||||
scrollLeft: 0,
|
||||
gridLeft: 0,
|
||||
}
|
||||
|
||||
export const createStores = () => {
|
||||
const reorder = writable(reorderInitialState)
|
||||
const isReordering = derived(
|
||||
reorder,
|
||||
$reorder => !!$reorder.sourceColumn,
|
||||
false
|
||||
)
|
||||
return {
|
||||
reorder,
|
||||
isReordering,
|
||||
}
|
||||
}
|
||||
|
||||
export const deriveStores = context => {
|
||||
const { reorder, columns, visibleColumns, scroll, bounds, stickyColumn, ui } =
|
||||
context
|
||||
|
||||
// Callback when dragging on a colum header and starting reordering
|
||||
const startReordering = (column, e) => {
|
||||
const $visibleColumns = get(visibleColumns)
|
||||
const $bounds = get(bounds)
|
||||
const $scroll = get(scroll)
|
||||
const $stickyColumn = get(stickyColumn)
|
||||
ui.actions.blur()
|
||||
|
||||
// Generate new breakpoints for the current columns
|
||||
let breakpoints = $visibleColumns.map(col => ({
|
||||
x: col.left + col.width,
|
||||
column: col.name,
|
||||
}))
|
||||
if ($stickyColumn) {
|
||||
breakpoints.unshift({
|
||||
x: 0,
|
||||
column: $stickyColumn.name,
|
||||
})
|
||||
}
|
||||
|
||||
// Update state
|
||||
reorder.set({
|
||||
sourceColumn: column,
|
||||
targetColumn: null,
|
||||
breakpoints,
|
||||
initialMouseX: e.clientX,
|
||||
scrollLeft: $scroll.left,
|
||||
gridLeft: $bounds.left,
|
||||
})
|
||||
|
||||
// Add listeners to handle mouse movement
|
||||
document.addEventListener("mousemove", onReorderMouseMove)
|
||||
document.addEventListener("mouseup", stopReordering)
|
||||
|
||||
// Trigger a move event immediately so ensure a candidate column is chosen
|
||||
onReorderMouseMove(e)
|
||||
}
|
||||
|
||||
// Callback when moving the mouse when reordering columns
|
||||
const onReorderMouseMove = e => {
|
||||
const $reorder = get(reorder)
|
||||
|
||||
// Compute the closest breakpoint to the current position
|
||||
let targetColumn
|
||||
let minDistance = Number.MAX_SAFE_INTEGER
|
||||
const mouseX = e.clientX - $reorder.gridLeft + $reorder.scrollLeft
|
||||
$reorder.breakpoints.forEach(point => {
|
||||
const distance = Math.abs(point.x - mouseX)
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance
|
||||
targetColumn = point.column
|
||||
}
|
||||
})
|
||||
|
||||
if (targetColumn !== $reorder.targetColumn) {
|
||||
reorder.update(state => ({
|
||||
...state,
|
||||
targetColumn,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Callback when stopping reordering columns
|
||||
const stopReordering = async () => {
|
||||
// Swap position of columns
|
||||
let { sourceColumn, targetColumn } = get(reorder)
|
||||
moveColumn(sourceColumn, targetColumn)
|
||||
|
||||
// Reset state
|
||||
reorder.set(reorderInitialState)
|
||||
|
||||
// Remove event handlers
|
||||
document.removeEventListener("mousemove", onReorderMouseMove)
|
||||
document.removeEventListener("mouseup", stopReordering)
|
||||
|
||||
// Save column changes
|
||||
await columns.actions.saveChanges()
|
||||
}
|
||||
|
||||
// Moves a column after another columns.
|
||||
// An undefined target column will move the source to index 0.
|
||||
const moveColumn = (sourceColumn, targetColumn) => {
|
||||
let $columns = get(columns)
|
||||
let sourceIdx = $columns.findIndex(x => x.name === sourceColumn)
|
||||
let targetIdx = $columns.findIndex(x => x.name === targetColumn)
|
||||
targetIdx++
|
||||
columns.update(state => {
|
||||
const removed = state.splice(sourceIdx, 1)
|
||||
if (--targetIdx < sourceIdx) {
|
||||
targetIdx++
|
||||
}
|
||||
state.splice(targetIdx, 0, removed[0])
|
||||
return state.slice()
|
||||
})
|
||||
}
|
||||
|
||||
// Moves a column one place left (as appears visually)
|
||||
const moveColumnLeft = async column => {
|
||||
const $visibleColumns = get(visibleColumns)
|
||||
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
|
||||
moveColumn(column, $visibleColumns[sourceIdx - 2]?.name)
|
||||
await columns.actions.saveChanges()
|
||||
}
|
||||
|
||||
// Moves a column one place right (as appears visually)
|
||||
const moveColumnRight = async column => {
|
||||
const $visibleColumns = get(visibleColumns)
|
||||
const sourceIdx = $visibleColumns.findIndex(x => x.name === column)
|
||||
if (sourceIdx === $visibleColumns.length - 1) {
|
||||
return
|
||||
}
|
||||
moveColumn(column, $visibleColumns[sourceIdx + 1]?.name)
|
||||
await columns.actions.saveChanges()
|
||||
}
|
||||
|
||||
return {
|
||||
reorder: {
|
||||
...reorder,
|
||||
actions: {
|
||||
startReordering,
|
||||
stopReordering,
|
||||
moveColumnLeft,
|
||||
moveColumnRight,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue