mirror of
https://github.com/nextcloud/richdocuments.git
synced 2025-12-17 21:12:14 +01:00
refactor: move template settings to vue component
Signed-off-by: Elizabeth Danzberger <lizzy7128@tutanota.de>
This commit is contained in:
parent
938876af1b
commit
fc11c12ae1
10 changed files with 396 additions and 354 deletions
112
css/admin.scss
112
css/admin.scss
|
|
@ -10,37 +10,21 @@
|
||||||
|
|
||||||
#richdocuments {
|
#richdocuments {
|
||||||
#use_group_select, #edit_group_select {
|
#use_group_select, #edit_group_select {
|
||||||
width: 200px; display: block;
|
width: 200px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#s2id_use_group_select,
|
#s2id_use_group_select,
|
||||||
#s2id_edit_group_select {
|
#s2id_edit_group_select {
|
||||||
margin-left: 18px;
|
margin-left: 18px;
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
width: 300px !important;
|
width: 300px !important;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
input#zoteroAPIKeyField {
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea#documentSigningCertField {
|
|
||||||
width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea#documentSigningKeyField {
|
|
||||||
width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea#documentSigningCaField {
|
|
||||||
width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#richdocuments,
|
|
||||||
#richdocuments-templates {
|
|
||||||
// inline buttons on section headers
|
// inline buttons on section headers
|
||||||
> h2 {
|
> h2 {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -58,6 +42,7 @@ textarea#documentSigningCaField {
|
||||||
line-height: 44px;
|
line-height: 44px;
|
||||||
padding-left: 44px;
|
padding-left: 44px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
|
|
@ -67,77 +52,18 @@ textarea#documentSigningCaField {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#richdocuments-templates {
|
textarea#documentSigningCertField {
|
||||||
> input {
|
width: 600px;
|
||||||
// feedback for keyboard navigation
|
}
|
||||||
&:hover,
|
|
||||||
&:focus,
|
textarea#documentSigningKeyField {
|
||||||
&:active {
|
width: 600px;
|
||||||
+ h2 .icon-add,
|
}
|
||||||
+ h2 .icon-loading-small {
|
|
||||||
opacity: 0.7;
|
textarea#documentSigningCaField {
|
||||||
}
|
width: 600px;
|
||||||
+ #emptycontent label {
|
}
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
input#zoteroAPIKeyField {
|
||||||
}
|
width: 300px;
|
||||||
}
|
|
||||||
ul:not(.hidden) {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
li {
|
|
||||||
$size: 150px;
|
|
||||||
$sizeY: math.div($size, 210) * 297;
|
|
||||||
$space: 10px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
margin: $space;
|
|
||||||
position: relative;
|
|
||||||
figure {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: $size;
|
|
||||||
margin: $space;
|
|
||||||
img, .templatePlaceholder {
|
|
||||||
width: $size;
|
|
||||||
height: $sizeY;
|
|
||||||
background-color: var(--color-background-dark);
|
|
||||||
}
|
|
||||||
figcaption {
|
|
||||||
margin-top: $space;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.delete-cover,
|
|
||||||
.delete-template {
|
|
||||||
width: $size;
|
|
||||||
height: $sizeY;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
margin: $space;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 250ms ease-in-out;
|
|
||||||
z-index: 3;
|
|
||||||
line-height: $sizeY;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 20px;
|
|
||||||
background-size: 24px;
|
|
||||||
// text is set as bg
|
|
||||||
color: var(--color-background-darker);
|
|
||||||
}
|
|
||||||
.delete-cover {
|
|
||||||
// bg is set as color
|
|
||||||
background-color: var(--color-text-lighter);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
&:hover .delete-template,
|
|
||||||
.delete-template:focus,
|
|
||||||
.delete-template.icon-loading {
|
|
||||||
opacity: 1;
|
|
||||||
+ .delete-cover {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,9 @@ describe('Office admin settings', function() {
|
||||||
cy.get('.settings-entry.font-list-settings').contains(font)
|
cy.get('.settings-entry.font-list-settings').contains(font)
|
||||||
})
|
})
|
||||||
|
|
||||||
// FIXME: Template settings only get visible after reload
|
cy.get('.settings-section__name')
|
||||||
cy.reload()
|
.contains('Global Templates')
|
||||||
cy.get('#richdocuments-templates')
|
|
||||||
.scrollIntoView()
|
.scrollIntoView()
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('Create new office files from templates', function() {
|
import {User} from "@nextcloud/cypress";
|
||||||
|
|
||||||
|
describe('Global templates', function() {
|
||||||
|
|
||||||
let randUser
|
let randUser
|
||||||
before(function() {
|
before(function() {
|
||||||
|
|
@ -15,29 +17,54 @@ describe('Create new office files from templates', function() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Create a new file from a user template', function() {
|
it('Can be uploaded', function() {
|
||||||
cy.visit('/apps/files')
|
cy.intercept('POST', '**/richdocuments/template').as('templateUploadRequest')
|
||||||
|
cy.uploadSystemTemplate({
|
||||||
|
fixturePath: 'templates/presentation.otp',
|
||||||
|
fileName: 'systemtemplate.otp',
|
||||||
|
mimeType: 'application/vnd.oasis.opendocument.presentation-template',
|
||||||
|
})
|
||||||
|
|
||||||
cy.get('[data-cy-upload-picker=""]')
|
cy.wait('@templateUploadRequest').then(({ response }) => {
|
||||||
.should('be.visible')
|
expect(response.statusCode).to.equal(201)
|
||||||
.as('newFileMenu')
|
expect(response.body.data.name).to.equal('systemtemplate.otp')
|
||||||
|
expect(response.body.data.type).to.equal('presentation')
|
||||||
cy.get('@newFileMenu').click()
|
})
|
||||||
cy.get('button[role="menuitem"]').contains('New presentation').click()
|
|
||||||
|
|
||||||
cy.get('input[data-cy-files-new-node-dialog-input=""]').type('FileFromTemplate')
|
|
||||||
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
|
||||||
|
|
||||||
cy.get('form.templates-picker__form').as('templatePicker')
|
|
||||||
cy.get('@templatePicker').contains('presentation').click()
|
|
||||||
cy.get('@templatePicker').find('input[type="submit"]').click()
|
|
||||||
|
|
||||||
cy.waitForViewer()
|
|
||||||
cy.waitForCollabora()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Create a file from a system template as user', () => {
|
it('Can prevent uploading a duplicate', function() {
|
||||||
cy.uploadSystemTemplate()
|
cy.uploadSystemTemplate({
|
||||||
|
fixturePath: 'templates/presentation.otp',
|
||||||
|
fileName: 'systemtemplate.otp',
|
||||||
|
mimeType: 'application/vnd.oasis.opendocument.presentation-template',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.toast-error').contains('Template "systemtemplate.otp" already exists').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can be deleted', function() {
|
||||||
|
cy.login(new User('admin', 'admin'))
|
||||||
|
cy.visit('/settings/admin/richdocuments')
|
||||||
|
|
||||||
|
cy.get('.settings-section__name')
|
||||||
|
.contains('Global Templates')
|
||||||
|
.scrollIntoView()
|
||||||
|
|
||||||
|
cy.intercept('DELETE', '**/richdocuments/template/*').as('templateDeleteRequest')
|
||||||
|
cy.get('.template-btn[data-cy-template-btn-name="systemtemplate"]').click()
|
||||||
|
|
||||||
|
cy.wait('@templateDeleteRequest').then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(204)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Can be created by a user', () => {
|
||||||
|
cy.uploadSystemTemplate({
|
||||||
|
fixturePath: 'templates/presentation.otp',
|
||||||
|
fileName: 'systemtemplate.otp',
|
||||||
|
mimeType: 'application/vnd.oasis.opendocument.presentation-template',
|
||||||
|
})
|
||||||
|
|
||||||
cy.login(randUser)
|
cy.login(randUser)
|
||||||
cy.visit('/apps/files')
|
cy.visit('/apps/files')
|
||||||
|
|
||||||
|
|
@ -53,8 +80,15 @@ describe('Create new office files from templates', function() {
|
||||||
|
|
||||||
cy.get('form.templates-picker__form').as('templatePicker')
|
cy.get('form.templates-picker__form').as('templatePicker')
|
||||||
cy.get('@templatePicker').contains('systemtemplate').click()
|
cy.get('@templatePicker').contains('systemtemplate').click()
|
||||||
|
|
||||||
|
cy.intercept('POST', '**/templates/create').as('templateCreateRequest')
|
||||||
cy.get('@templatePicker').find('input[type="submit"]').click()
|
cy.get('@templatePicker').find('input[type="submit"]').click()
|
||||||
|
|
||||||
|
cy.wait('@templateCreateRequest').then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(200)
|
||||||
|
expect(response.body.ocs.data.basename).to.equal('FileFromSystemTemplate.odp')
|
||||||
|
})
|
||||||
|
|
||||||
cy.waitForViewer()
|
cy.waitForViewer()
|
||||||
cy.waitForCollabora()
|
cy.waitForCollabora()
|
||||||
})
|
})
|
||||||
|
|
@ -101,76 +135,99 @@ describe('Create new office files from templates', function() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Create templates with fields', () => {
|
describe('User templates', function() {
|
||||||
let randUser
|
it.skip('Create a new file from a user template', function() {
|
||||||
|
|
||||||
before(() => {
|
|
||||||
cy.createRandomUser().then(user => {
|
|
||||||
randUser = user
|
|
||||||
|
|
||||||
cy.login(randUser)
|
|
||||||
cy.visit('/apps/files')
|
|
||||||
|
|
||||||
// Create a templates folder
|
|
||||||
cy.get('[data-cy-upload-picker=""]')
|
|
||||||
.should('be.visible')
|
|
||||||
.as('newFileMenu')
|
|
||||||
|
|
||||||
cy.get('@newFileMenu').click()
|
|
||||||
cy.get('button[role="menuitem"]').contains('Create templates folder').click()
|
|
||||||
|
|
||||||
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
|
||||||
|
|
||||||
// Upload the fixtures into the templates folder
|
|
||||||
cy.uploadFile(randUser, 'templates/document_template_with_fields.odt', 'application/vnd.oasis.opendocument.text', '/Templates/document.odt')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Create a document from a template with fields', () => {
|
|
||||||
const fields = [
|
|
||||||
{ type: 'rich-text', alias: 'Name', content: 'Nextcloud' },
|
|
||||||
{ type: 'rich-text', alias: 'Favorite app', content: 'richdocuments' },
|
|
||||||
{ type: 'checkbox', alias: 'Uses Nextcloud at home', checked: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
cy.visit('/apps/files')
|
cy.visit('/apps/files')
|
||||||
|
|
||||||
// Create a new document
|
|
||||||
cy.get('[data-cy-upload-picker=""]')
|
cy.get('[data-cy-upload-picker=""]')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.as('newFileMenu')
|
.as('newFileMenu')
|
||||||
|
|
||||||
cy.get('@newFileMenu').click()
|
cy.get('@newFileMenu').click()
|
||||||
cy.get('button[role="menuitem"]').contains('New document').click()
|
cy.get('button[role="menuitem"]').contains('New presentation').click()
|
||||||
|
|
||||||
cy.get('input[data-cy-files-new-node-dialog-input=""]').type('FileFromTemplateWithFields')
|
cy.get('input[data-cy-files-new-node-dialog-input=""]').type('FileFromTemplate')
|
||||||
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
||||||
|
|
||||||
// Choose the document template
|
|
||||||
cy.get('form.templates-picker__form').as('templatePicker')
|
cy.get('form.templates-picker__form').as('templatePicker')
|
||||||
cy.get('@templatePicker').contains('document').click()
|
cy.get('@templatePicker').contains('presentation').click()
|
||||||
cy.get('@templatePicker').find('input[type="submit"]').click()
|
cy.get('@templatePicker').find('input[type="submit"]').click()
|
||||||
|
|
||||||
// Intercept the POST request to verify the correct fields are submitted
|
cy.waitForViewer()
|
||||||
cy.intercept('POST', '**/templates/create', (req) => {
|
cy.waitForCollabora()
|
||||||
const templateFields = Object.values(req.body.templateFields)
|
})
|
||||||
|
|
||||||
expect(templateFields[0].content).to.equal(fields[0].content)
|
describe('Create templates with fields', () => {
|
||||||
expect(templateFields[1].content).to.equal(fields[1].content)
|
let randUser
|
||||||
|
|
||||||
req.continue()
|
before(() => {
|
||||||
}).as('reqFillFields')
|
cy.createRandomUser().then(user => {
|
||||||
|
randUser = user
|
||||||
|
|
||||||
cy.submitTemplateFields(fields)
|
cy.login(randUser)
|
||||||
|
cy.visit('/apps/files')
|
||||||
|
|
||||||
// Wait for the response and collect the file ID of the created file
|
// Create a templates folder
|
||||||
cy.wait('@reqFillFields').then(({ response }) => {
|
cy.get('[data-cy-upload-picker=""]')
|
||||||
cy.wrap(response.body.ocs.data.fileid).as('createdFileId')
|
.should('be.visible')
|
||||||
|
.as('newFileMenu')
|
||||||
|
|
||||||
|
cy.get('@newFileMenu').click()
|
||||||
|
cy.get('button[role="menuitem"]').contains('Create templates folder').click()
|
||||||
|
|
||||||
|
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
||||||
|
|
||||||
|
// Upload the fixtures into the templates folder
|
||||||
|
cy.uploadFile(randUser, 'templates/document_template_with_fields.odt', 'application/vnd.oasis.opendocument.text', '/Templates/document.odt')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test if the fields currently match the values we passed to the template
|
it('Create a document from a template with fields', () => {
|
||||||
cy.get('@createdFileId').then(createdFileId => {
|
const fields = [
|
||||||
cy.verifyTemplateFields(fields, createdFileId)
|
{ type: 'rich-text', alias: 'Name', content: 'Nextcloud' },
|
||||||
|
{ type: 'rich-text', alias: 'Favorite app', content: 'richdocuments' },
|
||||||
|
{ type: 'checkbox', alias: 'Uses Nextcloud at home', checked: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
cy.visit('/apps/files')
|
||||||
|
|
||||||
|
// Create a new document
|
||||||
|
cy.get('[data-cy-upload-picker=""]')
|
||||||
|
.should('be.visible')
|
||||||
|
.as('newFileMenu')
|
||||||
|
|
||||||
|
cy.get('@newFileMenu').click()
|
||||||
|
cy.get('button[role="menuitem"]').contains('New document').click()
|
||||||
|
|
||||||
|
cy.get('input[data-cy-files-new-node-dialog-input=""]').type('FileFromTemplateWithFields')
|
||||||
|
cy.get('button[data-cy-files-new-node-dialog-submit=""]').click()
|
||||||
|
|
||||||
|
// Choose the document template
|
||||||
|
cy.get('form.templates-picker__form').as('templatePicker')
|
||||||
|
cy.get('@templatePicker').contains('document').click()
|
||||||
|
cy.get('@templatePicker').find('input[type="submit"]').click()
|
||||||
|
|
||||||
|
// Intercept the POST request to verify the correct fields are submitted
|
||||||
|
cy.intercept('POST', '**/templates/create', (req) => {
|
||||||
|
const templateFields = Object.values(req.body.templateFields)
|
||||||
|
|
||||||
|
expect(templateFields[0].content).to.equal(fields[0].content)
|
||||||
|
expect(templateFields[1].content).to.equal(fields[1].content)
|
||||||
|
|
||||||
|
req.continue()
|
||||||
|
}).as('reqFillFields')
|
||||||
|
|
||||||
|
cy.submitTemplateFields(fields)
|
||||||
|
|
||||||
|
// Wait for the response and collect the file ID of the created file
|
||||||
|
cy.wait('@reqFillFields').then(({ response }) => {
|
||||||
|
cy.wrap(response.body.ocs.data.fileid).as('createdFileId')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test if the fields currently match the values we passed to the template
|
||||||
|
cy.get('@createdFileId').then(createdFileId => {
|
||||||
|
cy.verifyTemplateFields(fields, createdFileId)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -293,16 +293,19 @@ Cypress.Commands.add('verifyOpen', (filename) => {
|
||||||
.should('contain.text', filename)
|
.should('contain.text', filename)
|
||||||
})
|
})
|
||||||
|
|
||||||
Cypress.Commands.add('uploadSystemTemplate', () => {
|
Cypress.Commands.add('uploadSystemTemplate', ({ fixturePath, fileName, mimeType }) => {
|
||||||
cy.login(new User('admin', 'admin'))
|
cy.login(new User('admin', 'admin'))
|
||||||
cy.visit('/settings/admin/richdocuments')
|
cy.visit('/settings/admin/richdocuments')
|
||||||
cy.get('#richdocuments-templates').scrollIntoView()
|
|
||||||
cy.get('input[type=file]#add-template').selectFile({
|
cy.get('.settings-section__name')
|
||||||
contents: 'cypress/fixtures/templates/presentation.otp',
|
.contains('Global Templates')
|
||||||
fileName: 'systemtemplate.otp',
|
.scrollIntoView()
|
||||||
mimeType: 'application/vnd.oasis.opendocument.presentation-template',
|
|
||||||
|
cy.get('.settings-section input[type="file"]').selectFile({
|
||||||
|
contents: `cypress/fixtures/${fixturePath}`,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
}, { force: true })
|
}, { force: true })
|
||||||
cy.get('#richdocuments-templates li').contains('systemtemplate.otp')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Cypress.Commands.add('submitTemplateFields', (fields) => {
|
Cypress.Commands.add('submitTemplateFields', (fields) => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ namespace OCA\Richdocuments\Service;
|
||||||
use OCA\Richdocuments\AppConfig;
|
use OCA\Richdocuments\AppConfig;
|
||||||
use OCA\Richdocuments\AppInfo\Application;
|
use OCA\Richdocuments\AppInfo\Application;
|
||||||
use OCA\Richdocuments\Db\Wopi;
|
use OCA\Richdocuments\Db\Wopi;
|
||||||
|
use OCA\Richdocuments\TemplateManager;
|
||||||
use OCA\Theming\ImageManager;
|
use OCA\Theming\ImageManager;
|
||||||
use OCP\AppFramework\Services\IInitialState;
|
use OCP\AppFramework\Services\IInitialState;
|
||||||
use OCP\Defaults;
|
use OCP\Defaults;
|
||||||
|
|
@ -24,6 +25,7 @@ class InitialStateService {
|
||||||
private IInitialState $initialState,
|
private IInitialState $initialState,
|
||||||
private AppConfig $appConfig,
|
private AppConfig $appConfig,
|
||||||
private ImageManager $imageManager,
|
private ImageManager $imageManager,
|
||||||
|
private TemplateManager $templateManager,
|
||||||
private CapabilitiesService $capabilitiesService,
|
private CapabilitiesService $capabilitiesService,
|
||||||
private IURLGenerator $urlGenerator,
|
private IURLGenerator $urlGenerator,
|
||||||
private Defaults $themingDefaults,
|
private Defaults $themingDefaults,
|
||||||
|
|
@ -58,6 +60,13 @@ class InitialStateService {
|
||||||
$this->provideOptions();
|
$this->provideOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function provideAdminSettings(): void {
|
||||||
|
$this->initialState->provideInitialState('adminSettings', [
|
||||||
|
'templatesAvailable' => $this->capabilitiesService->hasTemplateSource(),
|
||||||
|
'templates' => $this->templateManager->getSystemFormatted(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function prepareParams(array $params): array {
|
public function prepareParams(array $params): array {
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'instanceId' => $this->config->getSystemValue('instanceid'),
|
'instanceId' => $this->config->getSystemValue('instanceid'),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class Admin implements ISettings {
|
||||||
|
|
||||||
public function getForm(): TemplateResponse {
|
public function getForm(): TemplateResponse {
|
||||||
$this->initialStateService->provideCapabilities();
|
$this->initialStateService->provideCapabilities();
|
||||||
|
$this->initialStateService->provideAdminSettings();
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
'richdocuments',
|
'richdocuments',
|
||||||
'admin',
|
'admin',
|
||||||
|
|
@ -45,8 +47,6 @@ class Admin implements ISettings {
|
||||||
'external_apps' => $this->config->getAppValue('richdocuments', 'external_apps'),
|
'external_apps' => $this->config->getAppValue('richdocuments', 'external_apps'),
|
||||||
'canonical_webroot' => $this->config->getAppValue('richdocuments', 'canonical_webroot'),
|
'canonical_webroot' => $this->config->getAppValue('richdocuments', 'canonical_webroot'),
|
||||||
'disable_certificate_verification' => $this->config->getAppValue('richdocuments', 'disable_certificate_verification', '') === 'yes',
|
'disable_certificate_verification' => $this->config->getAppValue('richdocuments', 'disable_certificate_verification', '') === 'yes',
|
||||||
'templates' => $this->manager->getSystemFormatted(),
|
|
||||||
'templatesAvailable' => $this->capabilitiesService->hasTemplateSource(),
|
|
||||||
'settings' => $this->appConfig->getAppSettings(),
|
'settings' => $this->appConfig->getAppSettings(),
|
||||||
'demo_servers' => $this->demoService->fetchDemoServers(),
|
'demo_servers' => $this->demoService->fetchDemoServers(),
|
||||||
'web_server' => strtolower($_SERVER['SERVER_SOFTWARE']),
|
'web_server' => strtolower($_SERVER['SERVER_SOFTWARE']),
|
||||||
|
|
@ -59,11 +59,11 @@ class Admin implements ISettings {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSection() {
|
public function getSection(): string {
|
||||||
return 'richdocuments';
|
return 'richdocuments';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPriority() {
|
public function getPriority(): int {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
src/admin.js
118
src/admin.js
|
|
@ -4,8 +4,6 @@
|
||||||
*/
|
*/
|
||||||
import './init-shared.js'
|
import './init-shared.js'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import axios from '@nextcloud/axios'
|
|
||||||
import { generateUrl } from '@nextcloud/router'
|
|
||||||
import AdminSettings from './components/AdminSettings.vue'
|
import AdminSettings from './components/AdminSettings.vue'
|
||||||
import '../css/admin.scss'
|
import '../css/admin.scss'
|
||||||
|
|
||||||
|
|
@ -29,119 +27,3 @@ const element = document.getElementById('admin-vue')
|
||||||
new Vue({
|
new Vue({
|
||||||
render: h => h(AdminSettings, { props: { initial: JSON.parse(element.dataset.initial) } }),
|
render: h => h(AdminSettings, { props: { initial: JSON.parse(element.dataset.initial) } }),
|
||||||
}).$mount('#admin-vue')
|
}).$mount('#admin-vue')
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a new template to the dom
|
|
||||||
*
|
|
||||||
* @param {object} data the template data from the template controller response
|
|
||||||
*/
|
|
||||||
function appendTemplateFromData(data) {
|
|
||||||
const template = document.querySelector('.template-model').cloneNode(true)
|
|
||||||
template.className = ''
|
|
||||||
template.dataset.filename = data.name
|
|
||||||
template.querySelector('img').src = data.preview
|
|
||||||
template.querySelector('figcaption').textContent = data.name
|
|
||||||
template.querySelector('.delete-template').href = data.delete
|
|
||||||
|
|
||||||
document.querySelector('#richdocuments-templates > ul').appendChild(template)
|
|
||||||
template.querySelector('.delete-template').addEventListener('click', deleteTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete template event handler
|
|
||||||
*
|
|
||||||
* @param {Event} event the button click event
|
|
||||||
*/
|
|
||||||
function deleteTemplate(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const emptyElmt = document.querySelector('#richdocuments-templates #emptycontent')
|
|
||||||
const tplListElmt = document.querySelector('#richdocuments-templates > ul')
|
|
||||||
const elmt = event.target
|
|
||||||
|
|
||||||
// ensure no request is in progress
|
|
||||||
if (elmt.className.indexOf('loading') === -1 && elmt.textContent === '') {
|
|
||||||
const remote = event.target.href
|
|
||||||
elmt.classList.add('icon-loading')
|
|
||||||
elmt.classList.remove('icon-delete')
|
|
||||||
|
|
||||||
// send request
|
|
||||||
axios.delete(remote)
|
|
||||||
.then(function() {
|
|
||||||
// remove template
|
|
||||||
elmt.parentElement.remove()
|
|
||||||
// is list empty? Only the default template is left
|
|
||||||
if (tplListElmt.querySelectorAll('li').length === 1) {
|
|
||||||
tplListElmt.classList.add('hidden')
|
|
||||||
emptyElmt.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
// failure, show warning
|
|
||||||
elmt.textContent = t('richdocuments', 'Error')
|
|
||||||
elmt.classList.remove('icon-loading')
|
|
||||||
setTimeout(function() {
|
|
||||||
elmt.classList.add('icon-delete')
|
|
||||||
elmt.textContent = ''
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Init the upload manager and the delete template handler
|
|
||||||
*/
|
|
||||||
function initTemplateManager() {
|
|
||||||
const inputElmt = document.querySelector('#add-template')
|
|
||||||
const buttonElmt = document.querySelector('.icon-add')
|
|
||||||
const deleteElmts = document.querySelectorAll('.delete-template')
|
|
||||||
const emptyElmt = document.querySelector('#richdocuments-templates #emptycontent')
|
|
||||||
const tplListElmt = document.querySelector('#richdocuments-templates > ul')
|
|
||||||
|
|
||||||
deleteElmts.forEach(function(elmt) {
|
|
||||||
elmt.addEventListener('click', deleteTemplate)
|
|
||||||
})
|
|
||||||
|
|
||||||
// fileupload plugin
|
|
||||||
$('#richdocuments-templates').fileupload({
|
|
||||||
dataType: 'json',
|
|
||||||
url: generateUrl('apps/richdocuments/template'),
|
|
||||||
type: 'POST',
|
|
||||||
|
|
||||||
add(e, data) {
|
|
||||||
// submit on file selection
|
|
||||||
data.submit()
|
|
||||||
inputElmt.disabled = true
|
|
||||||
buttonElmt.className = 'icon-loading-small'
|
|
||||||
},
|
|
||||||
|
|
||||||
submit(e, data) {
|
|
||||||
data.formData = _.extend(data.formData || {}, {
|
|
||||||
requesttoken: OC.requestToken,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
success(e) {
|
|
||||||
document.querySelector(`[data-filename="${e.data.name}"]`)?.remove()
|
|
||||||
inputElmt.disabled = false
|
|
||||||
buttonElmt.className = 'icon-add'
|
|
||||||
// add template to dom
|
|
||||||
appendTemplateFromData(e.data)
|
|
||||||
tplListElmt.classList.remove('hidden')
|
|
||||||
emptyElmt.classList.add('hidden')
|
|
||||||
},
|
|
||||||
|
|
||||||
fail(e, data) {
|
|
||||||
// failure, show warning
|
|
||||||
buttonElmt.className = 'icon-add'
|
|
||||||
buttonElmt.textContent = t('richdocuments', 'An error occurred') + ': ' + data.jqXHR.responseJSON.data.message
|
|
||||||
setTimeout(function() {
|
|
||||||
inputElmt.disabled = false
|
|
||||||
buttonElmt.textContent = ''
|
|
||||||
}, 2000)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
initTemplateManager()
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,8 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GlobalTemplates v-if="isSetup" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -406,6 +408,7 @@ import SettingsSelectGroup from './SettingsSelectGroup.vue'
|
||||||
import SettingsExternalApps from './SettingsExternalApps.vue'
|
import SettingsExternalApps from './SettingsExternalApps.vue'
|
||||||
import SettingsInputFile from './SettingsInputFile.vue'
|
import SettingsInputFile from './SettingsInputFile.vue'
|
||||||
import SettingsFontList from './SettingsFontList.vue'
|
import SettingsFontList from './SettingsFontList.vue'
|
||||||
|
import GlobalTemplates from './AdminSettings/GlobalTemplates.vue'
|
||||||
|
|
||||||
import '@nextcloud/dialogs/style.css'
|
import '@nextcloud/dialogs/style.css'
|
||||||
import { getCallbackBaseUrl } from '../helpers/url.js'
|
import { getCallbackBaseUrl } from '../helpers/url.js'
|
||||||
|
|
@ -435,6 +438,7 @@ export default {
|
||||||
SettingsExternalApps,
|
SettingsExternalApps,
|
||||||
SettingsInputFile,
|
SettingsInputFile,
|
||||||
SettingsFontList,
|
SettingsFontList,
|
||||||
|
GlobalTemplates,
|
||||||
NcModal,
|
NcModal,
|
||||||
NcNoteCard,
|
NcNoteCard,
|
||||||
},
|
},
|
||||||
|
|
@ -533,9 +537,6 @@ export default {
|
||||||
else this.serverError = Object.values(getCapabilities().collabora).length > 0 ? SERVER_STATE_OK : SERVER_STATE_CONNECTION_ERROR
|
else this.serverError = Object.values(getCapabilities().collabora).length > 0 ? SERVER_STATE_OK : SERVER_STATE_CONNECTION_ERROR
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isSetup() {
|
|
||||||
this.toggleTemplateSettings()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
for (const key in this.initial.settings) {
|
for (const key in this.initial.settings) {
|
||||||
|
|
@ -581,7 +582,6 @@ export default {
|
||||||
}
|
}
|
||||||
this.checkIfDemoServerIsActive()
|
this.checkIfDemoServerIsActive()
|
||||||
this.checkSettings()
|
this.checkSettings()
|
||||||
this.toggleTemplateSettings()
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async checkSettings() {
|
async checkSettings() {
|
||||||
|
|
@ -815,13 +815,6 @@ export default {
|
||||||
this.settings.fonts.splice(index, 1)
|
this.settings.fonts.splice(index, 1)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleTemplateSettings() {
|
|
||||||
if (this.isSetup) {
|
|
||||||
document.getElementById('richdocuments-templates').classList.remove('hidden')
|
|
||||||
} else {
|
|
||||||
document.getElementById('richdocuments-templates').classList.add('hidden')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
217
src/components/AdminSettings/GlobalTemplates.vue
Normal file
217
src/components/AdminSettings/GlobalTemplates.vue
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NcSettingsSection v-if="templatesAvailable"
|
||||||
|
:name="t('richdocuments', 'Global Templates')">
|
||||||
|
<input ref="newTemplateInput"
|
||||||
|
type="file"
|
||||||
|
class="hidden-visually"
|
||||||
|
@change="selectFile">
|
||||||
|
|
||||||
|
<div class="template-buttons">
|
||||||
|
<NcButton type="tertiary-no-background" @click="newTemplate">
|
||||||
|
<div class="template-btn new-template-btn">
|
||||||
|
<div class="template-icon">
|
||||||
|
<NewTemplateIcon :size="38" />
|
||||||
|
</div>
|
||||||
|
<span>{{ t('richdocuments', 'New') }}</span>
|
||||||
|
</div>
|
||||||
|
</NcButton>
|
||||||
|
|
||||||
|
<div v-for="template in existingTemplates" :key="template.id">
|
||||||
|
<NcButton type="tertiary-no-background"
|
||||||
|
@click="deleteTemplate(template.id)">
|
||||||
|
<div class="template-btn" :data-cy-template-btn-name="basename(template.name)">
|
||||||
|
<div class="template-icon"
|
||||||
|
:style="`background-image: url(${template.preview})`">
|
||||||
|
<div class="template-delete-overlay">
|
||||||
|
<DeleteIcon :size="38" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span :title="template.name">
|
||||||
|
{{ basename(template.name) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NcSettingsSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { NcSettingsSection, NcButton } from '@nextcloud/vue'
|
||||||
|
import { translate as t } from '@nextcloud/l10n'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||||
|
import { loadState } from '@nextcloud/initial-state'
|
||||||
|
import '@nextcloud/dialogs/style.css'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import NewTemplateIcon from 'vue-material-design-icons/FileDocumentPlusOutline.vue'
|
||||||
|
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GlobalTemplates',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
NcSettingsSection,
|
||||||
|
NcButton,
|
||||||
|
NewTemplateIcon,
|
||||||
|
DeleteIcon,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
existingTemplates: [],
|
||||||
|
templatesAvailable: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
// Later maybe we can retrieve these settings from AdminSettings.vue`
|
||||||
|
// and pass them in as props (once AdminSettings is cleaned up)
|
||||||
|
const settings = loadState('richdocuments', 'adminSettings', {})
|
||||||
|
|
||||||
|
this.templatesAvailable = settings.templatesAvailable
|
||||||
|
this.existingTemplates = settings.templates?.filter((template) => {
|
||||||
|
return template.name !== 'Empty'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
t,
|
||||||
|
newTemplate() {
|
||||||
|
this.$refs.newTemplateInput?.click()
|
||||||
|
},
|
||||||
|
async selectFile() {
|
||||||
|
const selectedFile = this.$refs.newTemplateInput?.files[0]
|
||||||
|
const templateAlreadyExists = this.existingTemplates.some((template) => {
|
||||||
|
return template.name === selectedFile.name
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!templateAlreadyExists) {
|
||||||
|
const template = await this.uploadTemplate(selectedFile)
|
||||||
|
|
||||||
|
this.existingTemplates.push(template)
|
||||||
|
showSuccess(t('richdocuments', 'Uploaded template "{name}"', { name: template.name }))
|
||||||
|
} else {
|
||||||
|
showError(t('richdocuments', 'Template "{name}" already exists', { name: selectedFile.name }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async uploadTemplate(file) {
|
||||||
|
const url = generateUrl('/apps/richdocuments/template')
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
formData.append('files', file)
|
||||||
|
|
||||||
|
let res = null
|
||||||
|
try {
|
||||||
|
res = await axios.post(url, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.response.data.data.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data.data
|
||||||
|
},
|
||||||
|
async deleteTemplate(templateId) {
|
||||||
|
const url = generateUrl('/apps/richdocuments/template/' + templateId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(url)
|
||||||
|
} catch {
|
||||||
|
showError(t('richdocuments', 'Unable to delete template'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateIndex = this.existingTemplates.findIndex((template) => {
|
||||||
|
return template.id === templateId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (templateIndex !== -1) {
|
||||||
|
this.existingTemplates.splice(templateIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(t('richdocuments', 'Deleted template'))
|
||||||
|
},
|
||||||
|
basename(filename) {
|
||||||
|
return filename.substr(0, filename.lastIndexOf('.'))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
$padding: calc(var(--default-grid-baseline) * 3);
|
||||||
|
|
||||||
|
.template-buttons {
|
||||||
|
display: grid;
|
||||||
|
gap: calc(var(--default-grid-baseline) * 4);
|
||||||
|
grid-template-columns: repeat(auto-fit, 175px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
border:
|
||||||
|
var(--border-width-input)
|
||||||
|
solid
|
||||||
|
var(--color-border)
|
||||||
|
;
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
width: 175px;
|
||||||
|
height: calc(175px * 1.5);
|
||||||
|
padding: $padding;
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-align: start;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: normal;
|
||||||
|
flex-basis: var(--default-line-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-btn:hover .template-delete-overlay {
|
||||||
|
background-color: var(--color-box-shadow);
|
||||||
|
|
||||||
|
svg { visibility: visible; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-icon {
|
||||||
|
flex-basis: 100%;
|
||||||
|
display: flex;
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
background-size: cover;
|
||||||
|
margin-bottom: $padding;
|
||||||
|
|
||||||
|
svg { color: var(--color-text-lighter); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-delete-overlay {
|
||||||
|
border-radius: var(--border-radius-element);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-basis: 100%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
visibility: hidden;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-template-btn {
|
||||||
|
.template-icon { justify-content: center; }
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,51 +4,8 @@
|
||||||
* SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
|
* SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
script('richdocuments', 'richdocuments-admin');
|
\OCP\Util::addScript('richdocuments', 'richdocuments-admin');
|
||||||
script('files', 'jquery.fileupload');
|
|
||||||
|
|
||||||
/** @var array $_ */
|
/** @var array $_ */
|
||||||
?>
|
?>
|
||||||
<div id="admin-vue" data-initial="<?php p(json_encode($_['settings'], true)); ?>"></div>
|
<div id="admin-vue" data-initial="<?php p(json_encode($_['settings'], true)); ?>"></div>
|
||||||
|
|
||||||
<?php if ($_['settings']['templatesAvailable'] === true) { ?>
|
|
||||||
<form class="section hidden" id="richdocuments-templates" method="post" action="/template/">
|
|
||||||
<input name="files" class="hidden-visually" id="add-template" type="file" />
|
|
||||||
<h2>
|
|
||||||
<?php p($l->t('Global templates')) ?>
|
|
||||||
<label for="add-template" class="icon-add" title="<?php p($l->t('Add a new template')); ?>"></label>
|
|
||||||
</h2>
|
|
||||||
<div id="emptycontent" class="<?php p(empty($_['settings']['templates'])?:'hidden') ?>">
|
|
||||||
<div class="icon-file"></div>
|
|
||||||
<h2>
|
|
||||||
<?php p($l->t('No templates defined.')); ?>
|
|
||||||
</h2>
|
|
||||||
<label for="add-template"><?php p($l->t('Add a new one?')); ?></label>
|
|
||||||
</div>
|
|
||||||
<ul class="<?php p(!empty($_['settings']['templates'])?:'hidden') ?>">
|
|
||||||
<li class="hidden template-model">
|
|
||||||
<figure>
|
|
||||||
<img src="" alt="<?php p($l->t('template preview')) ?>" />
|
|
||||||
<figcaption></figcaption>
|
|
||||||
</figure>
|
|
||||||
<a href="" class="delete-template icon-delete"></a>
|
|
||||||
<div class="delete-cover"></div>
|
|
||||||
</li>
|
|
||||||
<?php foreach ($_['settings']['templates'] as $template) {?>
|
|
||||||
<li data-filename="<?php p($template['name']); ?>">
|
|
||||||
<figure>
|
|
||||||
<?php if (isset($template['preview'])) { ?>
|
|
||||||
<img src="<?php p($template['preview']) ?>?y=297&x=210" alt="<?php p($l->t('template preview')) ?>" />
|
|
||||||
<?php } else { ?>
|
|
||||||
<div class="templatePlaceholder"></div>
|
|
||||||
<?php } ?>
|
|
||||||
<figcaption><?php p($template['name']) ?></figcaption>
|
|
||||||
</figure>
|
|
||||||
<?php if (isset($template['delete'])) { ?><a href="<?php p($template['delete']) ?>" class="delete-template icon-delete"></a><?php } ?>
|
|
||||||
<div class="delete-cover"></div>
|
|
||||||
</li>
|
|
||||||
<?php } ?>
|
|
||||||
</ul>
|
|
||||||
</form>
|
|
||||||
<?php } ?>
|
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue