% use Mojo::JSON qw( encode_json );
% layout 'yancy';
% content_for head => begin
%= javascript "/yancy/vue.js"
%= javascript "/yancy/marked.js"
%= stylesheet begin
main {
position: relative;
margin-top: 10px;
}
.clickable {
cursor: pointer;
}
th.clickable:hover {
background: #dee2e6;
}
.sidebar {
position: fixed;
padding: 0;
left: 0;
top: 0;
bottom: 0;
padding-top: 66px;
background: #f8f9fa;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
overflow: scroll;
z-index: 10;
}
.sidebar .nav-link {
color: #666;
}
.sidebar .nav-link:hover {
color: #212529;
}
.sidebar .nav-link.active {
color: #007bff;
}
.sidebar .sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
.sidebar .sidebar-top {
display: none;
}
.toast-container {
position: absolute;
top: 0.4em;
right: 1em;
width: 200px;
z-index: 1000;
}
.data-table tbody tr:nth-child( odd ) td,
.data-table thead th {
white-space: nowrap;
max-width: 30em;
overflow: hidden;
text-overflow: ellipsis;
}
.data-table tbody tr:nth-child( odd ).open + tr td {
padding-bottom: 0.75em;
}
.data-table tbody tr:nth-child( even ) td {
padding: 0;
border: 0;
}
.data-table textarea, .add-item textarea {
width: 100%;
}
.add-item {
margin-top: 0.75em;
}
.add-field-element {
display: flex;
}
.field-element-button {
margin-right: 1em;
width: 2.75em;
height: 2.75em;
}
.yes-no input {
display: none;
}
.yes-no label {
min-width: 3.25em;
}
.markdown-editor-panels {
width: calc( 100% - 2px );
height: 50vh;
border: 1px solid #ccc;
border-radius: .25em;
}
.markdown-editor-panels > div,
.markdown-editor-panels > textarea {
display: inline-block;
width: 49%;
height: 100%;
vertical-align: top;
box-sizing: border-box;
padding: 0 20px;
overflow: scroll;
}
div.markdown-editor-panels > textarea {
border: none;
border-right: 1px solid #ccc;
resize: none;
outline: none;
background-color: #f6f6f6;
font-size: 14px;
font-family: 'Monaco', courier, monospace;
padding: 20px;
}
.filters .filter-desc {
vertical-align: middle;
}
/* Fix Bootstrap 4 problem: https://github.com/twbs/bootstrap/issues/23454 */
.is-invalid + .invalid-feedback {
display: block;
}
@media (min-width: 575.98px) {
/* Force sidebar to always be open on larger screens */
#sidebar-collapse.collapse {
display: block;
}
}
@media (min-width: 992px) {
.markdown-preview {
display: none;
}
.markdown-editor .collapse {
display: inline-block;
}
}
@media (max-width: 575.98px) {
.sidebar {
position: absolute;
top: 0;
left: 0;
bottom: auto;
right: 0;
padding-top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .1);
}
.sidebar-collapse {
height: 0;
}
.sidebar .sidebar-top {
display: flex;
}
main {
margin-top: 66px;
}
}
@media (max-width: 992px) {
div.markdown-editor-panels > div,
div.markdown-editor-panels > textarea {
display: block;
width: 100%;
}
div.markdown-editor-panels > div.collapse,
div.markdown-editor-panels > textarea.collapse {
display: none;
}
}
% end
% end
% for my $include ( @{ app->yancy->editor->include } ) {
%= include $include
% }
<main id="app" class="container-fluid">
<div v-if="hasSchema" class="sidebar col-sm-3 col-md-3 col-lg-2">
<div class="bg-dark p-2 justify-content-between sidebar-top">
<button class="btn btn-outline-light" type="button"
data-toggle="collapse" data-target="#sidebar-collapse"
aria-controls="sidebar-collapse" aria-expanded="false"
aria-label="<%= l 'Toggle navigation' %>">
<i class="fa fa-bars"></i> <%= l 'Menu' %>
</button>
<a class="btn btn-outline-light" href="<%= stash 'return_to' %>">
<i class="fa fa-sign-out"></i> <%= l 'Back' %>
</a>
</div>
<div id="sidebar-collapse" class="collapse">
% my $menu = app->yancy->editor->menu;
% for my $category ( sort keys %$menu ) {
% my $menu_items = $menu->{ $category };
<h6 class="sidebar-heading text-muted px-3 mt-3">
<%= $category %>
</h6>
<ul class="nav flex-column" data-menu="<%= $category %>">
% for my $item ( @$menu_items ) {
<% my $link_destination =
$item->{component} ? (
sprintf '@click.prevent="setComponent( %s )"', qq{'$item->{component}'}
)
: ''
;
%>
<li class="nav-item">
<a href="#" class="nav-link" data-menu-item="<%= $item->{title} %>"
<%== $link_destination %>
>
%= $item->{title}
</a>
</li>
% }
</ul>
% }
<h6 class="sidebar-heading text-muted px-3 mt-2">
<%= l 'Schema' %>
</h6>
<ul id="sidebar-schema-list" class="nav flex-column">
<!-- schema list -->
<li v-for="( val, key ) in schema" class="nav-item">
<a href="#" @click.prevent="setSchema( key )"
class="nav-link" :data-schema="key"
:class="currentSchema == key ? 'active' : ''"
>
{{ val.operations.list.schema.title || key }}
</a>
</li>
</ul>
</div>
</div>
<div v-if="hasSchema !== null && !hasSchema" class="alert alert-danger" role="alert">
<h4 class="alert-heading"><%= l 'No Schema Configured' %></h4>
<p class="mb-0"><%== l 'No Schema Configured description' %></p>
</div>
<div v-show="error && error.fetchSpec" class="alert alert-danger" role="alert">
<h4 class="alert-heading"><%= l 'Error Fetching API Spec' %></h4>
<p><%= l 'Error from server:' %> {{ error.fetchSpec }}</p>
<p class="mb-0"><%= l 'Error Fetching API Spec description' %></p>
</div>
<div class="row">
<div v-show="currentComponent" class="offset-sm-3 col-sm-9 offset-md-3 col-md-9 offset-lg-2 col-lg-10 table-responsive-md">
<keep-alive><component ref="currentComponent" :is="currentComponent"></component></keep-alive>
</div>
<div v-show="currentSchema && !currentComponent" class="offset-sm-3 col-sm-9 offset-md-3 col-md-9 offset-lg-2 col-lg-10 table-responsive-md">
<!-- current schema -->
<h2>{{ currentSchema.title ? currentSchema.title : currentSchemaName }}</h2>
<div v-if="currentSchema.description"
v-html="marked( currentSchema.description )"></div>
<div v-show="error && error.fetchPage" class="alert alert-danger" role="alert">
<h4 class="alert-heading"><%= l 'Error Fetching Data' %></h4>
<p class="mb-0"><%= l 'Error from server:' %> {{ error.fetchPage }}</p>
</div>
<div class="schema-buttons d-flex">
<button @click="fetchPage()" class="btn btn-primary mr-1"><%= l 'Refresh' %></button>
<a v-if="currentSchema['x-view-url']" :href="currentSchema['x-view-url']" class="btn btn-secondary mr-1">
<i class="fa fa-eye" aria-hidden="true"></i> <%= l 'View' %>
</a>
<button @click="newFilter = { }" class="new-filter btn btn-outline-secondary mr-1">
<%= l 'Add Filter' %>
</button>
<button @click="showAddItem()" id="add-item-btn" class="btn btn-outline-secondary ml-auto"
:class="{active:addingItem}"
>
<%= l 'Add Item' %>
</button>
</div>
<div v-if="addingItem" class="add-item">
<div v-if="error && error.addItem" class="alert alert-danger" role="alert">
<h4 class="alert-heading"><%= l 'Error Adding Item' %></h4>
<p class="mb-0"><%= l 'Error from server:' %> {{ error.addItem }}</p>
<p v-if="error.details">
<button class="btn" type="button" data-toggle="collapse" data-target="#add-item-error-details" aria-expanded="false" aria-controls="add-item-error-details">
<%= l 'Show Details' %>
</button>
<div class="collapse" id="add-item-error-details">
<div class="card card-body">
{{ error.details }}
</div>
</div>
</p>
</div>
<item-form id="new-item-form" v-model="newItem"
:schema="currentOperations['add'].schema"
@close="cancelAddItem" @input="addItem"
:error="formError"
:show-read-only="false"
/>
</div>
<div class="filters mt-1">
<form id="filter-form" class="form-inline" v-if="newFilter"
@submit.prevent="addFilter()"
>
<select class="custom-select col-md-4 col-lg-4" v-model="newFilter.field">
<option v-for="col in columns" :value="col.field">
{{ col.title }}
</option>
</select>
<input class="form-control col-sm-12 col-md-4 col-lg-5" v-model="newFilter.value">
<span class="d-inline-flex justify-space-between col-lg-3 col-md-4" style="padding: 0">
<button style="flex: 1 1 50%" class="add-filter btn btn-outline-success"><%= l 'Add' %></button>
<button style="flex: 1 1 50%" @click.prevent="cancelFilter()" class="remove-filter btn btn-outline-danger"><%= l 'Remove' %></button>
</span>
</form>
<div v-for="( filter, i ) in filters">
<span class="filter-desc">Filter: <code>{{ filter.field }}</code> matches <code>{{ filter.value }}</code></span>
<button class="remove-filter btn btn-outline-danger" @click="removeFilter(i)">Remove</button>
</div>
</div>
<div class="table-responsive">
<table style="margin-top: 0.5em; width: 100%" class="table data-table" :data-schema="currentSchemaName">
<thead class="thead-light">
<tr>
<th></th>
<th v-for="( col, i ) in columns"
class="clickable"
@click="toggleSort( col )"
>
<i class="fa" :class="sortClass( col )"></i>
{{col.title}}
</th>
</tr>
</thead>
<tbody>
<template v-for="( row, i ) in items">
<tr :data-item-id="rowId( row )" :class="openedRow == i ? 'open' : ''">
<td style="width: 5em">
<a v-if="currentSchema['x-view-item-url']" :href="rowViewUrl(row)" title="View" class="view-button">
<i class="fa fa-eye" aria-hidden="true"></i>
</a>
<a href="#" @click.prevent="toggleRow(i)" title="Edit" class="edit-button">
<i class="fa fa-pencil-square-o" aria-hidden="true"></i>
</a>
<a href="#" @click.prevent="confirmDeleteItem(i)" title="Delete" class="delete-button">
<i class="fa fa-trash-o" aria-hidden="true"></i>
</a>
</td>
<td v-for="col in columns">
<span v-if="col.template" v-html="fillTemplate( col.template, row )"></span>
<span v-if="col.field && !col.template">{{ renderValue( col.field, row[col.field] ) }}</span>
</td>
</tr>
<tr>
<td :colspan="2 + columns.length">
<div v-if="openedRow == i" v-show="error && error.saveItem" class="alert alert-danger" role="alert">
<h4 class="alert-heading">Error Saving Item</h4>
<p class="mb-0"><%= l 'Error from server:' %> {{ error.saveItem }}</p>
<p v-if="error.details">
<button class="btn" type="button" data-toggle="collapse" data-target="#edit-item-error-details" aria-expanded="false" aria-controls="edit-item-error-details">
<%= l 'Show Details' %>
</button>
<div class="collapse" id="edit-item-error-details">
<div class="card card-body">
{{ error.details }}
</div>
</div>
</p>
</div>
<item-form class="edit-form" v-if="openedRow == i" v-model="items[i]"
:schema="currentOperations['set'].schema"
@close="toggleRow(i)" @input="saveItem(i)"
:error="formError"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p v-if="items.length == 0">
No items found.
<a href="#" @click.prevent="showAddItem()">Create an item</a>.
</p>
<nav aria-label="List page navigation">
<ul class="pagination">
<li class="page-item" :class="currentPage == 1 ? 'disabled' : ''">
<a class="page-link" href="#" aria-label="First"
@click.prevent="gotoPage( 1 )"
>
<span aria-hidden="true">«</span>
<span class="sr-only">First</span>
</a>
</li>
<li class="page-item" :class="currentPage == 1 ? 'disabled' : ''">
<a class="page-link" href="#" aria-label="Previous"
@click.prevent="gotoPage( currentPage - 1 )"
>
<span aria-hidden="true">‹</span>
<span class="sr-only">Previous</span>
</a>
</li>
<li class="page-item disabled" v-if="pagerPages[0] > 1">
<span class="page-link">…</span>
</li>
<li v-for="page in pagerPages" class="page-item"
:class="page == currentPage ? 'active': ''"
>
<a class="page-link" href="#" @click.prevent="gotoPage( page )">
{{ page }}
</a>
</li>
<li class="page-item disabled" v-if="pagerPages[ pagerPages.length - 1 ] < totalPages">
<span class="page-link">…</span>
</li>
<li class="page-item" :class="currentPage >= totalPages ? 'disabled' : ''">
<a class="page-link" href="#" aria-label="Next"
@click.prevent="gotoPage( currentPage + 1 )"
>
<span aria-hidden="true">›</span>
<span class="sr-only">Next</span>
</a>
</li>
<li class="page-item" :class="currentPage >= totalPages ? 'disabled' : ''">
<a class="page-link" href="#" aria-label="Last"
@click.prevent="gotoPage( totalPages )"
>
<span aria-hidden="true">»</span>
<span class="sr-only">Last</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
<div class="toast-container" aria-live="polite" aria-atomic="true">
<div v-for="toast in toasts" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i v-if="toast.icon" class="mr-2 fa" :class="toast.icon" aria-hidden="true"></i>
<strong class="mr-auto">{{ toast.title || "Alert" }}</strong>
<button @click="removeToast( event, toast )" type="button" class="ml-2 mb-1 close"
aria-label="Close"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div v-if="toast.text" class="toast-body">
{{ toast.text }}
</div>
</div>
</div>
<div id="confirmDelete" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this item?</p>
</div>
<div class="modal-footer">
<button @click.prevent="deleteItem()" type="button" class="btn btn-danger">Delete</button>
<button @click.prevent="cancelDeleteItem()" type="button" class="btn btn-secondary">Cancel</button>
</div>
</div>
</main>
<template id="item-form">
<div>
<form @submit.prevent="save()">
<div class="pull-right" style="margin-bottom: 4px">
<label class="btn btn-secondary">Show Raw <input type="checkbox" v-model="showRaw" /></label>
</div>
<button class="btn btn-primary save-button">Save</button>
<button type="button" @click="cancel()" class="btn btn-danger cancel-button">Cancel</button>
<div v-if="!showRaw">
<div v-for="( conf, i ) in properties" class="form-group" style="clear: both">
<label :for="'field-' + conf.name + '-' + _uid">
{{ conf.title }} {{ isRequired( conf.name ) ? '*' : '' }}
</label>
<edit-field v-model="$data._value[conf.name]"
:name="conf.name" :example="example[conf.name]" :schema="conf"
:required="isRequired( conf.name )" :valid="!error[conf.name]"
:aria-labelledby="'field-' + conf.name + '-desc-' + _uid"
/></edit-field>
<div v-show="error[conf.name]" class="invalid-feedback">Error: {{ error[ conf.name ] }}</div>
<small v-if="conf.description" :id="'field-' + conf.name + '-desc-' + _uid"
v-html="marked( conf.description )"></small>
</div>
</div>
<div v-if="showRaw && rawError" class="alert alert-danger" role="alert" style="clear: both">
<h4 class="alert-heading">Error Parsing JSON</h4>
<p class="mb-0">{{ rawError.message }}</p>
</div>
<textarea v-if="showRaw" rows="10" v-model="rawValue" @change="updateRaw"></textarea>
</form>
</div>
</template>
<template id="edit-field">
<div :class="!valid ? 'is-invalid' : ''">
<input v-if="fieldType != 'select' && fieldType != 'checkbox' && fieldType != 'markdown' && fieldType != 'textarea' && fieldType != 'file' && fieldType != 'array' && fieldType != 'foreign-key'"
:name="name"
:type="fieldType" :pattern="pattern" :required="required"
:inputmode="inputMode"
:minlength="minlength" :maxlength="maxlength"
:min="min" :max="max"
:readonly="readonly"
:placeholder="example"
v-model="$data._value" @change="input" class="form-control"
:class="!valid ? 'is-invalid' : ''"
/>
<textarea v-if="fieldType == 'textarea'" :required="required"
:name="name" :disabled="readonly" v-model="$data._value"
@change="input" :class="!valid ? 'is-invalid' : ''"
rows="5"></textarea>
<select v-if="fieldType == 'select'"
:name="name" :required="required" :disabled="readonly"
v-model="$data._value" @change="input"
class="custom-select"
:class="!valid ? 'is-invalid' : ''"
>
<option v-if="!required" :value="undefined">- empty -</option>
<option v-for="val in schema.enum">{{val}}</option>
</select>
<div v-if="fieldType == 'checkbox'"
class="btn-group yes-no"
>
<label class="btn btn-xs" :class="$data._value ? 'btn-success active' : 'btn-outline-success'"
@click="readonly || ( $data._value = true )"
>
<input type="radio" :name="_uid"
v-model="$data._value" @change="input" :value="true"
> Yes
</label>
<label class="btn btn-xs" :class="!$data._value ? 'btn-danger active' : 'btn-outline-danger'"
@click="readonly || ( $data._value = false )"
>
<input type="radio" :name="_uid"
v-model="$data._value" @change="input" :value="false"
> No
</label>
</div>
<div v-if="fieldType == 'markdown'" class="markdown-editor">
<div class="markdown-editor-button-bar mb-1">
<button type="button" class="btn btn-outline-secondary markdown-preview"
:class="{active:showHtml}" @click.prevent="showHtml = !showHtml"
>
Preview
</button>
</div>
<div class="markdown-editor-panels">
<textarea :name="name" v-model="$data._value" @change="input" :required="required" :disabled="readonly" :class="{collapse:showHtml}"></textarea>
<div v-html="html" class="markdown-preview" :data-name="name" :class="{collapse:!showHtml}"></div>
</div>
</div>
<div v-if="fieldType == 'array'">
<ul style="list-style: none;">
<li v-for="(child, ix) in children" class="add-field-element">
<button v-on:click="removeChild(ix)" type="button" class="btn btn-sm btn-outline-secondary field-element-button">
<i aria-hidden="true" class="fa fa-trash-o" ></i>
</button>
<div style="display:inline-block">
<edit-field
@input="input()"
v-model="child.value"
:schema="child.schema"
:name="child.name" :example="child.example"
:required="child.required" :valid="child.valid"
:aria-labelledby="'field-' + child.name + '-desc-' + _uid"
></edit-field>
</div>
</li>
<li class="add-field-element">
<button v-on:click="addChild" type="button" class="btn btn-sm btn-outline-secondary field-element-button">
<i aria-hidden="true" class="fa fa-plus" ></i>
</button>
</li>
</ul>
</div>
<upload-field v-if="fieldType == 'file'" :disabled="readonly"
v-model="$data._value" @input="input" :name="name"
:class="!valid ? 'is-invalid' : ''"
></upload-field>
<foreign-key-field v-if="fieldType == 'foreign-key'" :disabled="readonly"
v-model="$data._value" @input="input" :name="name"
:schema-name="$props.schema['x-foreign-key']"
:display-field="$props.schema['x-display-field']"
:value-field="$props.schema['x-value-field']"
:class="!valid ? 'is-invalid' : ''"
></foreign-key-field>
</div>
</template>
<template id="upload-field">
<div>
<label class="btn btn-secondary upload-button" :for="_uid">Upload File</label>
<input type="file" style="display: none;" :name="name" :id="_uid" @change="uploadFile">
<img v-if="value" :src="value" style="display: block; max-height: 50vh" />
</div>
</template>
<template id="foreign-key-field">
<div class="btn-group dropright foreign-key" :data-name="name" :id="uid" :class="loading ? 'loading' : 'loaded'">
<button type="button" :name="name" class="btn btn-secondary dropdown-toggle"
aria-haspopup="true" aria-expanded="false"
data-toggle="dropdown" data-boundary="viewport"
>
{{ displayValue }}
</button>
<div class="dropdown-menu pt-0" style="max-width: 80vw;">
<form @submit.prevent="submitSearch">
<div class="input-group p-1">
<input type="text" class="form-control" placeholder="Search" aria-label="Search"
v-model="searchQuery"
>
<div class="input-group-append">
<button @click.stop.prevent="submitSearch" class="btn btn-primary" type="button">Search</button>
</div>
</div>
</form>
<div class="list-group list-group-flush">
<button tabindex="0" class="list-group-item list-group-item-action" v-for="item in searchResults" @click.prevent="select(item)">
{{ item.display }}
</button>
</div>
</div>
</div>
</template>
<script>var specUrl = '<%= stash 'api_url' %>';</script>
%= javascript "/yancy/app.js"
%= javascript begin
Yancy.i18n = <%== encode_json {
'Data validation failed' => l( 'Data validation failed' ),
'Internal server error' => l( 'Internal server error' ),
} %>;
% end