mirror of
https://github.com/garraflavatra/rolens.git
synced 2025-01-18 13:07:58 +00:00
Table view
This commit is contained in:
parent
dc5b12ee0d
commit
14e5b1f806
@ -1,4 +1,4 @@
|
||||
import { int32, int64, isInt, uint64 } from './utils';
|
||||
import { canBeObjectId, int32, int64, isInt, uint64 } from './utils';
|
||||
|
||||
export function input(node, { autofocus, type, onValid, onInvalid, mandatory } = {
|
||||
autofocus: false,
|
||||
@ -47,6 +47,9 @@ export function input(node, { autofocus, type, onValid, onInvalid, mandatory } =
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'objectid':
|
||||
return !canBeObjectId(node.value) && 'Invalid string representation of an ObjectId';
|
||||
|
||||
case 'double':
|
||||
case 'decimal':
|
||||
default:
|
||||
|
70
frontend/src/components/clock.svelte
Normal file
70
frontend/src/components/clock.svelte
Normal file
@ -0,0 +1,70 @@
|
||||
<script>
|
||||
export let date = new Date();
|
||||
|
||||
$: hours = date.getHours();
|
||||
$: minutes = date.getMinutes();
|
||||
$: seconds = date.getSeconds();
|
||||
</script>
|
||||
|
||||
<svg viewBox="-50 -50 100 100" class="clock">
|
||||
<circle class="clock-face" r="48" />
|
||||
|
||||
<!-- markers -->
|
||||
{#each [ 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 ] as minute}
|
||||
<line class="major" y1="35" y2="45" transform="rotate({30 * minute})" />
|
||||
|
||||
{#each [ 1, 2, 3, 4 ] as offset}
|
||||
<line class="minor" y1="42" y2="45" transform="rotate({6 * (minute + offset)})" />
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<!-- hour hand -->
|
||||
<line class="hour" y1="2" y2="-20" transform="rotate({30 * hours + 0.5 * minutes})" />
|
||||
|
||||
<!-- minute hand -->
|
||||
<line class="minute" y1="4" y2="-30" transform="rotate({6 * minutes + 0.1 * seconds})" />
|
||||
|
||||
<!-- second hand -->
|
||||
<g transform="rotate({6 * seconds})">
|
||||
<line class="second" y1="10" y2="-38" />
|
||||
<line class="second-counterweight" y1="10" y2="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.clock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.clock-face {
|
||||
stroke: #333;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.minor {
|
||||
stroke: #999;
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.major {
|
||||
stroke: #333;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.hour {
|
||||
stroke: #333;
|
||||
}
|
||||
|
||||
.minute {
|
||||
stroke: #666;
|
||||
}
|
||||
|
||||
.second, .second-counterweight {
|
||||
stroke: rgb(180, 0, 0);
|
||||
}
|
||||
|
||||
.second-counterweight {
|
||||
stroke-width: 3;
|
||||
}
|
||||
</style>
|
201
frontend/src/components/datepicker.svelte
Normal file
201
frontend/src/components/datepicker.svelte
Normal file
@ -0,0 +1,201 @@
|
||||
<script>
|
||||
import { addDays, getWeek, isDate, isSameDay, startOfWeek } from 'date-fns';
|
||||
import { onMount } from 'svelte';
|
||||
import { daysAbbr, months } from '../utils';
|
||||
import Clock from './clock.svelte';
|
||||
import Icon from './icon.svelte';
|
||||
import Modal from './modal.svelte';
|
||||
|
||||
export let value;
|
||||
export let show = false;
|
||||
|
||||
const rows = [ ...Array(6).keys() ];
|
||||
const cols = [ ...Array(7).keys() ];
|
||||
const now = new Date();
|
||||
let year = now.getFullYear();
|
||||
let month = now.getMonth();
|
||||
let day = now.getDate();
|
||||
let hour = now.getHours();
|
||||
let minute = now.getMinutes();
|
||||
let second = now.getSeconds();
|
||||
let calendar = [];
|
||||
|
||||
onMount(() => setDateToValues(now));
|
||||
$: setValueToDate(year, month, day, hour, minute, second);
|
||||
$: setDateToValues(value);
|
||||
|
||||
function buildCalendar(y = year, m = month) {
|
||||
const date = new Date(y, m);
|
||||
let curDate = startOfWeek(date, { weekStartsOn: 1 });
|
||||
return rows.map(() => {
|
||||
const week = [];
|
||||
cols.forEach(() => {
|
||||
week.push(curDate);
|
||||
curDate = addDays(curDate, 1);
|
||||
});
|
||||
return week;
|
||||
});
|
||||
}
|
||||
|
||||
function setValueToDate(date) {
|
||||
if (isDate(date)) {
|
||||
year = date.getFullYear();
|
||||
month = date.getMonth();
|
||||
day = date.getDate();
|
||||
hour = date.getHours();
|
||||
minute = date.getMinutes();
|
||||
value = new Date(year, month, day, hour, minute, second);
|
||||
}
|
||||
else {
|
||||
if (hour < 0) {
|
||||
day--; hour = 23;
|
||||
}
|
||||
else if (hour > 23) {
|
||||
day++; hour = 0;
|
||||
}
|
||||
else if (minute < 0) {
|
||||
hour--; minute = 59;
|
||||
}
|
||||
else if (minute > 59) {
|
||||
hour++; minute = 0;
|
||||
}
|
||||
else if (second < 0) {
|
||||
minute--; second = 59;
|
||||
}
|
||||
else if (second > 59) {
|
||||
minute++; second = 0;
|
||||
}
|
||||
value = new Date(year, month, day, hour, minute, second);
|
||||
}
|
||||
calendar = buildCalendar(year, month);
|
||||
}
|
||||
|
||||
function setDateToValues(date) {
|
||||
year = date.getFullYear();
|
||||
month = date.getMonth();
|
||||
day = date.getDate();
|
||||
hour = date.getHours();
|
||||
minute = date.getMinutes();
|
||||
second = date.getSeconds();
|
||||
calendar = buildCalendar(year, month);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal width="700px" bind:show>
|
||||
<div class="datepicker">
|
||||
<div class="date">
|
||||
<div class="field">
|
||||
<input type="number" bind:value={day} />
|
||||
<select bind:value={month}>
|
||||
{#each months as monthName, index}
|
||||
<option value={index}>{monthName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="number" bind:value={year} />
|
||||
</div>
|
||||
|
||||
<table class="calendar">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{#each daysAbbr as dayName}
|
||||
<th>{dayName}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{#each calendar as week}
|
||||
<tr>
|
||||
<td class="week">{getWeek(week[0])}</td>
|
||||
{#each week as day}
|
||||
<td class="day">
|
||||
<button
|
||||
on:click={() => setValueToDate(day)}
|
||||
type="button"
|
||||
class="btn-sm"
|
||||
class:active={isSameDay(value, day)}
|
||||
class:notinmonth={day.getMonth() !== month}
|
||||
>{day.getDate()}</button>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="time">
|
||||
<div class="field">
|
||||
<input type="number" bind:value={hour} placeholder="hours" />
|
||||
<span class="label">:</span>
|
||||
<input type="number" bind:value={minute} placeholder="mins" />
|
||||
<span class="label">:</span>
|
||||
<input type="number" bind:value={second} placeholder="secs" />
|
||||
</div>
|
||||
<Clock date={value} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="footer">
|
||||
<button class="btn secondary" type="button" on:click={() => value = new Date()}>
|
||||
<Icon name="o" /> Set to now
|
||||
</button>
|
||||
<button class="btn" type="button" on:click={() => show = false}>
|
||||
<Icon name="check" /> OK
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.datepicker {
|
||||
display: grid;
|
||||
grid-template: 1fr / 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.calendar thead th {
|
||||
opacity: 0.5;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.calendar .week {
|
||||
text-align: right;
|
||||
opacity: 0.5;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.calendar .day button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.calendar .day button.active {
|
||||
background-color: #00008b;
|
||||
color: #fff;
|
||||
}
|
||||
.calendar .day button.notinmonth {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.time input {
|
||||
text-align: center;
|
||||
}
|
||||
.time :global(.clock) {
|
||||
height: 150px;
|
||||
width: 150px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: right;
|
||||
}
|
||||
</style>
|
136
frontend/src/components/forminput.svelte
Normal file
136
frontend/src/components/forminput.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { numericInputTypes } from '../utils';
|
||||
import { input } from '../actions';
|
||||
import Icon from './icon.svelte';
|
||||
import { ObjectId } from 'bson';
|
||||
import Datepicker from './datepicker.svelte';
|
||||
|
||||
export let column = {};
|
||||
export let value = undefined;
|
||||
export let valid = true;
|
||||
|
||||
const onValid = () => valid = true;
|
||||
const onInvalid = () => valid = false;
|
||||
let objectIdInput;
|
||||
let showDatepicker;
|
||||
$: type = column.inputType;
|
||||
$: mandatory = column.mandatory;
|
||||
|
||||
$: if ((value === undefined) && mandatory) {
|
||||
valid = false;
|
||||
}
|
||||
|
||||
function markInputValid(input) {
|
||||
input.setCustomValidity('');
|
||||
input.reportValidity();
|
||||
input.classList.remove('invalid');
|
||||
valid = true;
|
||||
}
|
||||
|
||||
function setObjectId(event) {
|
||||
if (valid) {
|
||||
value = new ObjectId(event.currentTarget?.value);
|
||||
}
|
||||
}
|
||||
|
||||
function generateObjectId() {
|
||||
value = new ObjectId();
|
||||
objectIdInput.value = value.toString();
|
||||
markInputValid(objectIdInput);
|
||||
objectIdInput.disabled = true;
|
||||
}
|
||||
|
||||
function editObjectId() {
|
||||
if (!objectIdInput) {
|
||||
return;
|
||||
}
|
||||
objectIdInput.disabled = false;
|
||||
objectIdInput.focus();
|
||||
}
|
||||
|
||||
function selectChange() {
|
||||
if ((value === undefined) && mandatory) {
|
||||
valid = false;
|
||||
}
|
||||
else {
|
||||
valid = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="forminput {type}">
|
||||
<div class="field">
|
||||
{#if type === 'string'}
|
||||
<input type="text" bind:value use:input={{ type, onValid, onInvalid, mandatory }} />
|
||||
{:else if type === 'objectid'}
|
||||
<input
|
||||
type="text"
|
||||
bind:this={objectIdInput}
|
||||
on:input={setObjectId}
|
||||
use:input={{ type, onValid, onInvalid, mandatory }}
|
||||
/>
|
||||
{:else if numericInputTypes.includes(type)}
|
||||
<input type="number" bind:value use:input={{ type, onValid, onInvalid, mandatory }} />
|
||||
{:else if type === 'bool'}
|
||||
<select bind:value on:change={selectChange}>
|
||||
<option value={undefined} disabled={mandatory}>Unset</option>
|
||||
<option value={true}>Yes / true</option>
|
||||
<option value={false}>No / false</option>
|
||||
</select>
|
||||
{:else if type === 'date'}
|
||||
<input type="text" readonly value={value?.toString() || '...'} on:focus={() => showDatepicker = true} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if type === 'objectid'}
|
||||
{#if objectIdInput?.disabled}
|
||||
<button class="btn-sm" type="button" title="Edit object id" on:click={editObjectId}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" title="Generate random object id" on:click={generateObjectId}>
|
||||
<Icon name="reload" />
|
||||
</button>
|
||||
{:else if type === 'date'}
|
||||
<button class="btn-sm" type="button" title="Edit date" on:click={() => showDatepicker = true}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
<button class="btn-sm" type="button" title="Set date to now" on:click={() => value = new Date()}>
|
||||
<Icon name="o" />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" title="Reset field to default value" on:click={() => value = undefined}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if type === 'date'}
|
||||
<Datepicker bind:value bind:show={showDatepicker} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.forminput {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forminput.date input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 6px;
|
||||
background-color: #fff;
|
||||
}
|
||||
.actions button:last-child {
|
||||
border-radius: 2px 6px 6px 2px;
|
||||
}
|
||||
|
||||
.forminput:hover .actions {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
@ -2,7 +2,8 @@
|
||||
import { contextMenu } from '../stores';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from './icon.svelte';
|
||||
import { resolveKeypath } from '../utils';
|
||||
import { resolveKeypath, setValue } from '../utils';
|
||||
import FormInput from './forminput.svelte';
|
||||
|
||||
export let items = [];
|
||||
export let columns = [];
|
||||
@ -14,18 +15,34 @@
|
||||
export let striped = true;
|
||||
export let hideObjectIndicators = false;
|
||||
export let hideChildrenToggles = false;
|
||||
export let canSelect = true;
|
||||
export let canRemoveItems = false;
|
||||
export let inputsValid = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const keypathProxies = {};
|
||||
const validity = {};
|
||||
let childrenOpen = {};
|
||||
let _items = [];
|
||||
|
||||
$: refresh(hideObjectIndicators, items);
|
||||
$: inputsValid = Object.values(validity).every(v => !!v);
|
||||
|
||||
function refresh(hideObjectIndicators, items) {
|
||||
_items = objectToArray(items).map(item => {
|
||||
item.children = objectToArray(item.children);
|
||||
return item;
|
||||
});
|
||||
|
||||
for (let index = 0; index < _items.length; index++) {
|
||||
keypathProxies[index] = new Proxy(_items, {
|
||||
get: (_items, key) => resolveKeypath(_items[index], key),
|
||||
set: (_items, key, value) => {
|
||||
setValue(_items[index], key, value);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function objectToArray(obj) {
|
||||
@ -41,6 +58,10 @@
|
||||
}
|
||||
|
||||
function select(itemKey) {
|
||||
if (!canSelect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeKey !== itemKey) {
|
||||
activeKey = itemKey;
|
||||
if (level === 0) {
|
||||
@ -75,6 +96,11 @@
|
||||
contextMenu.show(evt, item.menu);
|
||||
}
|
||||
|
||||
function removeItem(index) {
|
||||
items.splice(index, 1);
|
||||
items = items;
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return hideObjectIndicators ? '' : '[...]';
|
||||
@ -98,12 +124,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each _items as item}
|
||||
{#each _items as item, index}
|
||||
<tr
|
||||
on:click={() => select(item[key])}
|
||||
on:dblclick={() => doubleClick(item[key])}
|
||||
on:contextmenu|preventDefault={evt => showContextMenu(evt, item)}
|
||||
class:selected={!activePath[level + 1] && activePath.every(k => path.includes(k) || k === item[key]) && (activePath[level] === item[key])}
|
||||
class:selectable={canSelect}
|
||||
class:selected={canSelect && !activePath[level + 1] && activePath.every(k => path.includes(k) || k === item[key]) && (activePath[level] === item[key])}
|
||||
class:striped
|
||||
>
|
||||
{#if !hideChildrenToggles}
|
||||
@ -127,21 +154,35 @@
|
||||
</td>
|
||||
|
||||
{#each columns as column, columnIndex}
|
||||
{@const value = column.key?.includes('.') ? resolveKeypath(item, column.key) : item[column.key]}
|
||||
<td class:right={column.right} title={value}>
|
||||
<div class="value" style:margin-left="{level * 10}px">
|
||||
{formatValue(value)}
|
||||
</div>
|
||||
<td class:right={column.right} title={keypathProxies[index][column.key]}>
|
||||
{#if column.inputType}
|
||||
<FormInput {column} bind:value={keypathProxies[index][column.key]} bind:valid={validity[columnIndex]} />
|
||||
{:else}
|
||||
<div class="value" style:margin-left="{level * 10}px">
|
||||
{formatValue(keypathProxies[index][column.key])}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
{#if canRemoveItems}
|
||||
<td class="has-button">
|
||||
<button class="btn-sm" type="button" on:click={() => removeItem(index)}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
|
||||
{#if item.children && childrenOpen[item[key]]}
|
||||
<svelte:self
|
||||
{columns}
|
||||
{hideObjectIndicators}
|
||||
{key}
|
||||
{striped}
|
||||
{hideObjectIndicators}
|
||||
{hideChildrenToggles}
|
||||
{canSelect}
|
||||
{canRemoveItems}
|
||||
path={[ ...path, item[key] ]}
|
||||
items={item.children}
|
||||
level={level + 1}
|
||||
@ -157,7 +198,10 @@
|
||||
tr.striped:nth-of-type(even) td {
|
||||
background-color: #eee;
|
||||
}
|
||||
tr.selected td {
|
||||
tr.selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.selectable.selected td {
|
||||
background-color: #00008b !important;
|
||||
color: #fff;
|
||||
}
|
||||
@ -165,7 +209,6 @@
|
||||
td {
|
||||
padding: 2px;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
td.has-toggle {
|
||||
width: 20px;
|
||||
|
@ -1,16 +1,27 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import GridItems from './grid-items.svelte';
|
||||
// import Icon from './icon.svelte';
|
||||
import Icon from './icon.svelte';
|
||||
|
||||
export let columns = [];
|
||||
export let items = [];
|
||||
// export let actions = [];
|
||||
export let key = 'id';
|
||||
export let activePath = [];
|
||||
export let striped = true;
|
||||
export let showHeaders = false;
|
||||
export let hideObjectIndicators = false;
|
||||
export let hideChildrenToggles = false;
|
||||
export let canAddRows = false;
|
||||
export let canSelect = true;
|
||||
export let canRemoveItems = false;
|
||||
export let inputsValid = false;
|
||||
// export let actions = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function addRow() {
|
||||
dispatch('addRow');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
@ -38,6 +49,10 @@
|
||||
{#each columns as column}
|
||||
<th scope="col">{column.title || ''}</th>
|
||||
{/each}
|
||||
|
||||
{#if canRemoveItems}
|
||||
<th class="has-button"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
{/if}
|
||||
@ -48,13 +63,24 @@
|
||||
{columns}
|
||||
{key}
|
||||
{striped}
|
||||
{canSelect}
|
||||
{canRemoveItems}
|
||||
{hideObjectIndicators}
|
||||
{hideChildrenToggles}
|
||||
bind:activePath
|
||||
bind:inputsValid
|
||||
on:select
|
||||
on:trigger
|
||||
/>
|
||||
</tbody>
|
||||
|
||||
{#if canAddRows}
|
||||
<tfoot>
|
||||
<button class="btn-sm" type="button" on:click={addRow}>
|
||||
<Icon name="+" />
|
||||
</button>
|
||||
</tfoot>
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -86,8 +112,10 @@
|
||||
th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* tfoot button {
|
||||
margin-top: 0.5rem;
|
||||
} */
|
||||
</style>
|
||||
|
6
frontend/src/components/icon.svelte
vendored
6
frontend/src/components/icon.svelte
vendored
@ -73,5 +73,11 @@
|
||||
<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>
|
||||
{:else if name === 'target'}
|
||||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>
|
||||
{:else if name === 'trash'}
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
|
||||
{:else if name === 'anchor'}
|
||||
<circle cx="12" cy="5" r="3"/><path d="M12 22V8M5 12H2a10 10 0 0 0 20 0h-3"/>
|
||||
{:else if name === 'o'}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { isBsonBuiltin, isDate } from '../utils';
|
||||
import { isDate } from 'date-fns';
|
||||
import { isBsonBuiltin } from '../utils';
|
||||
import Grid from './grid.svelte';
|
||||
|
||||
export let data = [];
|
||||
|
@ -8,41 +8,42 @@
|
||||
export let kp = '';
|
||||
|
||||
const collapsedSymbol = '...';
|
||||
const getType = i => {
|
||||
if (i === null) {
|
||||
return 'null';
|
||||
}
|
||||
return typeof i;
|
||||
};
|
||||
|
||||
let displayOnly = true;
|
||||
let items;
|
||||
let isArray;
|
||||
let openBracket;
|
||||
let closeBracket;
|
||||
$: {
|
||||
items = getType(data) === 'object' ? Object.keys(data) : [];
|
||||
isArray = Array.isArray(data);
|
||||
openBracket = isArray ? '[' : '{';
|
||||
closeBracket = isArray ? ']' : '}';
|
||||
let collapsed;
|
||||
let invalid = false;
|
||||
let textarea;
|
||||
$: items = getType(data) === 'object' ? Object.keys(data) : [];
|
||||
$: isArray = Array.isArray(data);
|
||||
$: openBracket = isArray ? '[' : '{';
|
||||
$: closeBracket = isArray ? ']' : '}';
|
||||
$: collapsed = depth < level;
|
||||
$: textarea && resizeTextarea();
|
||||
|
||||
function getType(value) {
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
let collapsed;
|
||||
$: collapsed = depth < level;
|
||||
|
||||
const format = i => {
|
||||
switch (getType(i)) {
|
||||
function format(value) {
|
||||
switch (getType(value)) {
|
||||
case 'string':
|
||||
return `${i}`;
|
||||
return `${value}`;
|
||||
case 'function':
|
||||
return 'f () {...}';
|
||||
case 'symbol':
|
||||
return i.toString();
|
||||
return value.toString();
|
||||
default:
|
||||
return i;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const clicked = e => {
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
if (e.shiftKey) {
|
||||
if (depth == 0) {
|
||||
depth = 999;
|
||||
@ -52,26 +53,9 @@
|
||||
}
|
||||
}
|
||||
collapsed = !collapsed;
|
||||
};
|
||||
|
||||
let invalid = false;
|
||||
let dbg;
|
||||
|
||||
function json2data() {
|
||||
try {
|
||||
data = JSON.parse(dbg.value);
|
||||
invalid = false;
|
||||
}
|
||||
catch {
|
||||
invalid = true;
|
||||
if (dbg.value.trim == '') {
|
||||
data = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dragstart(e, keypath, value) {
|
||||
console.log('kp:', keypath);
|
||||
function onDragstart(e, keypath, value) {
|
||||
const item = {};
|
||||
item[keypath] = value;
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(item));
|
||||
@ -83,22 +67,41 @@
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
resizeTextarea();
|
||||
try {
|
||||
data = JSON.parse(textarea.value);
|
||||
invalid = false;
|
||||
}
|
||||
catch {
|
||||
invalid = true;
|
||||
if (textarea.value.trim == '') {
|
||||
data = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resizeTextarea() {
|
||||
textarea.style.overflowY = 'hidden';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if displayOnly}
|
||||
{#if items.length}
|
||||
<span class:root={level == 0} class:hidden={collapsed}>
|
||||
{#if draggable && isArray}
|
||||
<span on:dragstart={e => dragstart(e, kp, data)} draggable="true" class="bracket" on:click={clicked} tabindex="0">{openBracket}</span>
|
||||
<span on:dragstart={e => onDragstart(e, kp, data)} draggable="true" class="bracket" on:click={onClick} tabindex="0">{openBracket}</span>
|
||||
{:else}
|
||||
<span class="bracket" on:click={clicked} tabindex="0">{openBracket}</span>
|
||||
<span class="bracket" on:click={onClick} tabindex="0">{openBracket}</span>
|
||||
{/if}
|
||||
<ul on:dblclick={() => (readonly ? displayOnly = true : displayOnly = false)} >
|
||||
{#each items as i, idx}
|
||||
<li>
|
||||
{#if !isArray}
|
||||
{#if draggable}
|
||||
<span on:dragstart={e => dragstart(e, kp ? kp + '.' + i : i, data[i])} draggable="true" class="key">{i}:</span>
|
||||
<span on:dragstart={e => onDragstart(e, kp ? kp + '.' + i : i, data[i])} draggable="true" class="key">{i}:</span>
|
||||
{:else}
|
||||
<span class="key">{i}:</span>
|
||||
{/if}
|
||||
@ -107,7 +110,7 @@
|
||||
<svelte:self {readonly} {draggable} kp={kp ? kp + '.' + i : i} data={data[i]} {depth} level={level + 1} last={idx === items.length - 1} />
|
||||
{:else}
|
||||
{#if draggable}
|
||||
<span on:dragstart={e => dragstart(e, kp ? kp + '.' + i : i, data[i])} draggable="true" class="val {getType(data[i])}">{format(data[i])}</span>{#if idx < items.length - 1}<span draggable class="comma">,</span>{/if}
|
||||
<span on:dragstart={e => onDragstart(e, kp ? kp + '.' + i : i, data[i])} draggable="true" class="val {getType(data[i])}">{format(data[i])}</span>{#if idx < items.length - 1}<span draggable class="comma">,</span>{/if}
|
||||
{:else}
|
||||
<span class="val {getType(data[i])}">{format(data[i])}</span>{#if idx < items.length - 1}<span class="comma">,</span>{/if}
|
||||
{/if}
|
||||
@ -115,16 +118,14 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="bracket" on:click={clicked} tabindex="0">{closeBracket}</span>{#if !last}<span
|
||||
class="comma">,</span>
|
||||
{/if}
|
||||
<span class="bracket" on:click={onClick} tabindex="0">{closeBracket}</span>{#if !last}<span class="comma">,</span>{/if}
|
||||
</span>
|
||||
<span style="padding: {level == 0 ? 10 : 0}px;" class="bracket" class:hidden={!collapsed} on:click={clicked} tabindex="0">{openBracket}{collapsedSymbol}{closeBracket}</span>{#if !last && collapsed}<span class="comma">,</span>{/if}
|
||||
<span style="padding: {level == 0 ? 10 : 0}px;" class="bracket" class:hidden={!collapsed} on:click={onClick} tabindex="0">{openBracket}{collapsedSymbol}{closeBracket}</span>{#if !last && collapsed}<span class="comma">,</span>{/if}
|
||||
{:else}
|
||||
{@html isArray ? '[]' : '{}'}
|
||||
{/if}
|
||||
{:else}
|
||||
<textarea on:keydown="{onKeydown}" class="debug" spellcheck="false" bind:this={dbg} class:invalid on:input={json2data}>{JSON.stringify(data, null, 2)}</textarea>
|
||||
<textarea on:keydown={onKeydown} spellcheck="false" bind:this={textarea} class:invalid on:input={onInput}>{JSON.stringify(data, null, 2)}</textarea>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@ -187,7 +188,7 @@
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
textarea.debug {
|
||||
textarea {
|
||||
font-family: menlo, monospace;
|
||||
padding: 10px;
|
||||
flex: 1 0;
|
||||
@ -195,12 +196,12 @@
|
||||
font-size: 90%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
textarea.invalid {
|
||||
background: #ffe3e3;
|
||||
color: #b30202 !important;
|
||||
|
@ -47,6 +47,11 @@
|
||||
.objectviewer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.objectviewer .code :global(span.root) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -20,7 +20,7 @@
|
||||
<li class:active={tab.key === selectedKey}>
|
||||
<button class="tab" on:click={() => select(tab.key)}>{tab.title}</button>
|
||||
{#if tab.closable}
|
||||
<button class="close" on:click={() => dispatch('closeTab', tab.key)}>
|
||||
<button class="btn-sm" on:click={() => dispatch('closeTab', tab.key)}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
{/if}
|
||||
@ -86,17 +86,9 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.close {
|
||||
.btn-sm {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
padding: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
button.close:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
button.close:active {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { resolveKeypath, setValue } from '../../../../utils';
|
||||
import { inputTypes, resolveKeypath, setValue } from '../../../../utils';
|
||||
import Icon from '../../../../components/icon.svelte';
|
||||
import FormInput from './forminput.svelte';
|
||||
import FormInput from '../../../../components/forminput.svelte';
|
||||
|
||||
export let item = {};
|
||||
export let view = {};
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
const iconMap = {
|
||||
string: 'text',
|
||||
objectid: 'anchor',
|
||||
int: 'hash',
|
||||
long: 'hash',
|
||||
uint64: 'hash',
|
||||
@ -28,14 +29,10 @@
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
function reset(columnKey) {
|
||||
keypathProxy[columnKey] = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if item && view}
|
||||
{#each view?.columns?.filter(c => c.inputType !== 'none') || [] as column}
|
||||
{#each view?.columns?.filter(c => inputTypes.includes(c.inputType)) || [] as column}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control because FormInput contains one -->
|
||||
<label class="column">
|
||||
<div class="label">
|
||||
@ -49,9 +46,6 @@
|
||||
</div>
|
||||
<div class="input">
|
||||
<FormInput {column} bind:value={keypathProxy[column.key]} bind:valid={validity[column.key]} />
|
||||
<button type="button" class="btn" title="Reset value" on:click={() => reset(column.key)} disabled={keypathProxy[column.key] === undefined}>
|
||||
<Icon name="reload" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
@ -75,12 +69,6 @@
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: grid;
|
||||
grid-template: 1fr / 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background-color: rgba(140, 140, 140, 0.1);
|
||||
|
@ -1,73 +0,0 @@
|
||||
<script>
|
||||
import { isDate } from '../../../../utils';
|
||||
import { input } from '../../../../actions';
|
||||
|
||||
export let column = {};
|
||||
export let value = undefined;
|
||||
export let valid = true;
|
||||
|
||||
const onValid = () => valid = true;
|
||||
const onInvalid = () => valid = false;
|
||||
const numericTypes = [ 'int', 'long', 'uint64', 'double', 'decimal' ];
|
||||
let dateInput;
|
||||
let timeInput;
|
||||
$: type = column.inputType;
|
||||
$: mandatory = column.mandatory;
|
||||
|
||||
$: if (value === undefined) {
|
||||
dateInput && (dateInput.value = undefined);
|
||||
timeInput && (timeInput.value = undefined);
|
||||
mandatory && (valid = false);
|
||||
}
|
||||
|
||||
function setDate(event) {
|
||||
if (event?.currentTarget?.value) {
|
||||
if (!isDate(value)) {
|
||||
value = new Date(event.currentTarget.value);
|
||||
}
|
||||
const date = event.currentTarget.value.split('-').map(n => parseInt(n));
|
||||
value.setFullYear(date[0], date[1], date[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function setTime(event) {
|
||||
if (event?.currentTarget?.value) {
|
||||
const time = event.currentTarget.value.split(':').map(n => parseInt(n));
|
||||
value.setHours?.(time[0], time[1], 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectChange() {
|
||||
if ((value === undefined) && mandatory) {
|
||||
valid = false;
|
||||
}
|
||||
else {
|
||||
valid = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field {type}">
|
||||
{#if type === 'string'}
|
||||
<input type="text" bind:value use:input={{ type, onValid, onInvalid, mandatory }} />
|
||||
{:else if numericTypes.includes(type)}
|
||||
<input type="number" bind:value use:input={{ type, onValid, onInvalid, mandatory }} />
|
||||
{:else if type === 'bool'}
|
||||
<select bind:value on:change={selectChange}>
|
||||
<option value={undefined} disabled={mandatory}>Unset</option>
|
||||
<option value={true}>Yes / true</option>
|
||||
<option value={false}>No / false</option>
|
||||
</select>
|
||||
{:else if type === 'date'}
|
||||
{@const isNotDate = !isDate(value)}
|
||||
<input type="date" bind:this={dateInput} on:input={setDate} use:input />
|
||||
<input type="time" bind:this={timeInput} on:input={setTime} disabled={isNotDate} title={isNotDate ? 'Enter a date first' : ''} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field.date {
|
||||
display: grid;
|
||||
grid-template: 1fr / 3fr 1fr;
|
||||
}
|
||||
</style>
|
@ -151,7 +151,7 @@
|
||||
<option value="none">Hidden in form</option>
|
||||
<optgroup label="Strings">
|
||||
<option value="string">String</option>
|
||||
<option value="objectid">ObjectID</option>
|
||||
<option value="objectid">ObjectId</option>
|
||||
</optgroup>
|
||||
<optgroup label="Integers">
|
||||
<option value="int">Integer (32-bit, signed)</option>
|
||||
|
@ -6,6 +6,8 @@
|
||||
import Icon from '../../../components/icon.svelte';
|
||||
import Form from './components/form.svelte';
|
||||
import ObjectViewer from '../../../components/objectviewer.svelte';
|
||||
import Grid from '../../../components/grid.svelte';
|
||||
import { inputTypes, randomString } from '../../../utils';
|
||||
|
||||
export let collection;
|
||||
|
||||
@ -50,6 +52,10 @@
|
||||
objectViewerData = [ ...newItems ];
|
||||
}
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
newItems = [ ...newItems, {} ];
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={insert}>
|
||||
@ -64,10 +70,29 @@
|
||||
use:input={{ type: 'json', autofocus: true }}
|
||||
></textarea>
|
||||
</label>
|
||||
{:else}
|
||||
{:else if viewType === 'form'}
|
||||
<div class="form">
|
||||
<Form bind:item={newItems[0]} bind:valid={formValid} view={$views[collection.viewKey]} />
|
||||
</div>
|
||||
{:else if viewType === 'table'}
|
||||
<div class="table">
|
||||
<Grid
|
||||
key="id"
|
||||
items={newItems}
|
||||
columns={
|
||||
$views[collection.viewKey]?.columns
|
||||
?.filter(c => inputTypes.includes(c.inputType))
|
||||
.map(c => ({ ...c, id: randomString(8), title: c.key })) || []
|
||||
}
|
||||
showHeaders={true}
|
||||
canAddRows={true}
|
||||
canSelect={false}
|
||||
canRemoveItems={true}
|
||||
hideChildrenToggles={true}
|
||||
on:addRow={addRow}
|
||||
bind:inputsValid={formValid}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex">
|
||||
@ -114,6 +139,11 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -22,7 +22,7 @@
|
||||
<label for="autosubmitQuery">Autosubmit query</label>
|
||||
<span>
|
||||
<input type="checkbox" id="autosubmitQuery" bind:checked={$settings.autosubmitQuery} />
|
||||
<label for="autosubmitQuery">Query items automatically when you open a collection</label>
|
||||
<label for="autosubmitQuery">Query items automatically after opening a collection</label>
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
|
@ -85,6 +85,22 @@ select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.btn-sm:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.btn-sm:active {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.btn-sm svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ObjectId } from 'bson';
|
||||
import { get } from 'svelte/store';
|
||||
import { environment } from './stores';
|
||||
|
||||
@ -11,6 +12,18 @@ export const int32 = [ intMin(32), intMax(32) ];
|
||||
export const int64 = [ intMin(64), intMax(64) ];
|
||||
export const uint64 = [ 0, uintMax(64) ];
|
||||
|
||||
// Input types
|
||||
export const numericInputTypes = [ 'int', 'long', 'uint64', 'double', 'decimal' ];
|
||||
export const inputTypes = [ 'string', 'objectid', 'bool', 'date', ...numericInputTypes ];
|
||||
|
||||
// Months
|
||||
export const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ];
|
||||
export const monthsAbbr = months.map(m => m.slice(0, 3));
|
||||
|
||||
// Days
|
||||
export const days = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ];
|
||||
export const daysAbbr = days.map(d => d.slice(0, 3));
|
||||
|
||||
// Get a value from an object with a JSON path, from Webdesq core
|
||||
export function resolveKeypath(object, path) {
|
||||
const parts = path.split('.').flatMap(part => {
|
||||
@ -112,6 +125,12 @@ export function isBsonBuiltin(value) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isDate(value) {
|
||||
return (value instanceof Date) && !isNaN(value.getTime());
|
||||
export function canBeObjectId(value) {
|
||||
try {
|
||||
new ObjectId(value);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
31
package-lock.json
generated
Normal file
31
package-lock.json
generated
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "mongodup",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"date-fns": "^2.29.3"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
|
||||
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": {
|
||||
"version": "2.29.3",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
|
||||
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
|
||||
}
|
||||
}
|
||||
}
|
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"date-fns": "^2.29.3"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user