├── .github ├── .gitignore ├── workflows │ ├── jest.yaml │ ├── lint.yaml │ ├── test-coverage.yaml │ ├── docker-image.yaml │ ├── pkgdown.yaml │ └── R-CMD-check.yaml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── vignettes ├── .gitignore ├── templates.Rmd └── designer.Rmd ├── inst ├── utils │ ├── WORDLIST │ └── golem-config.yml ├── app │ └── www │ │ ├── avatar.png │ │ └── favicon.ico ├── rstudio │ └── addins.dcf ├── golem-config.yml └── srcjs │ ├── sortable │ └── LICENSE │ └── bs-custom-file-input │ └── bs-custom-file-input.min.js ├── LICENSE ├── _pkgdown.yml ├── man ├── figures │ ├── logo.png │ ├── example_app.gif │ ├── save_template.png │ └── example_app_filled.jpeg ├── component.Rd ├── designer-package.Rd ├── sidebarItem.Rd ├── designApp.Rd └── component_setting.Rd ├── R ├── _disable_autoload.R ├── designer.R ├── mod_options_utils.R ├── mod_canvas_srv.R ├── mod_canvas_utils.R ├── app_server.R ├── run_app.R ├── app_config.R ├── mod_options_ui.R ├── mod_settings_ui.R ├── mod_settings_srv.R ├── cache.R ├── mod_settings_utils.R ├── mod_canvas_ui.R ├── cicerone_guide.R ├── ui_utils.R ├── mod_template_ui.R ├── mod_code_srv.R ├── mod_code_ui.R ├── mod_sidebar_utils.R ├── mod_sidebar_srv.R ├── app_ui.R ├── mod_template_utils.R ├── json_to_rscript.R └── mod_template_srv.R ├── .dockerignore ├── tests ├── testthat.R └── testthat │ ├── test-template_utils.R │ ├── test-ui-modules.R │ ├── test-template_server.R │ ├── test-golem-recommended.R │ ├── test-cache.R │ ├── test-template.R │ ├── test-shinytest2.R │ └── test-json-to-r.R ├── srcjs ├── .babelrc ├── page │ ├── __tests__ │ │ ├── Page.test.js │ │ ├── FillPage.test.js │ │ ├── BasicPage.test.js │ │ ├── FixedPage.test.js │ │ ├── FluidPage.test.js │ │ ├── NavbarPage.test.js │ │ ├── BootstrapPage.test.js │ │ └── DashboardPage.test.js │ ├── BasicPage.js │ ├── FillPage.js │ ├── BootstrapPage.js │ ├── FixedPage.js │ ├── FluidPage.js │ ├── init.js │ ├── NavbarPage.js │ ├── Page.js │ ├── DashboardPage.js │ └── utils.js ├── component │ ├── __tests__ │ │ ├── Box.test.js │ │ ├── Row.test.js │ │ ├── Tab.test.js │ │ ├── Text.test.js │ │ ├── Button.test.js │ │ ├── Column.test.js │ │ ├── Header.test.js │ │ ├── Input.test.js │ │ ├── Output.test.js │ │ ├── Tabset.test.js │ │ ├── Callout.test.js │ │ ├── InfoBox.test.js │ │ ├── UserBox.test.js │ │ ├── ValueBox.test.js │ │ ├── DateInput.test.js │ │ ├── FileInput.test.js │ │ ├── BlockQuote.test.js │ │ ├── Checkbox.test.js │ │ ├── InputPanel.test.js │ │ ├── SelectInput.test.js │ │ ├── SliderInput.test.js │ │ ├── CheckboxGroup.test.js │ │ ├── utils.test.js │ │ └── Component.test.js │ ├── Header.js │ ├── Row.js │ ├── BlockQuote.js │ ├── InputPanel.js │ ├── Text.js │ ├── init.js │ ├── Column.js │ ├── Callout.js │ ├── Checkbox.js │ ├── SelectInput.js │ ├── FileInput.js │ ├── Input.js │ ├── ValueBox.js │ ├── InfoBox.js │ ├── Box.js │ ├── utils.js │ ├── CheckboxGroup.js │ ├── UserBox.js │ ├── Button.js │ ├── Component.js │ ├── DateInput.js │ ├── Output.js │ ├── SliderInput.js │ ├── Tab.js │ └── Tabset.js ├── .eslintrc.yml ├── build.js ├── build_dev.js ├── app │ ├── screenshot.js │ ├── index.js │ └── settings.js ├── input │ ├── canvas-page-input.js │ ├── utils.js │ └── canvas-canvas-input.js ├── README.md └── package.json ├── NAMESPACE ├── app.R ├── .gitignore ├── .Rbuildignore ├── designer.Rproj ├── rsconnect └── shinyapps.io │ └── ashbaldry │ └── designer.dcf ├── .lintr ├── NEWS.md ├── DESCRIPTION ├── Dockerfile └── README.md /.github/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | -------------------------------------------------------------------------------- /vignettes/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.R 3 | -------------------------------------------------------------------------------- /inst/utils/WORDLIST: -------------------------------------------------------------------------------- 1 | Lifecycle 2 | UI 3 | golem 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | YEAR: 2022 2 | COPYRIGHT HOLDER: Ashley Baldry 3 | -------------------------------------------------------------------------------- /_pkgdown.yml: -------------------------------------------------------------------------------- 1 | url: https://ashbaldry.github.io/designer/ 2 | template: 3 | bootstrap: 5 4 | 5 | -------------------------------------------------------------------------------- /man/figures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/man/figures/logo.png -------------------------------------------------------------------------------- /inst/app/www/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/inst/app/www/avatar.png -------------------------------------------------------------------------------- /inst/app/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/inst/app/www/favicon.ico -------------------------------------------------------------------------------- /R/_disable_autoload.R: -------------------------------------------------------------------------------- 1 | # Disabling shiny autoload 2 | 3 | # See ?shiny::loadSupport for more information 4 | -------------------------------------------------------------------------------- /man/figures/example_app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/man/figures/example_app.gif -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .RData 2 | .Rhistory 3 | .git 4 | .gitignore 5 | manifest.json 6 | rsconnect/ 7 | .Rproj.user 8 | -------------------------------------------------------------------------------- /man/figures/save_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/man/figures/save_template.png -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | library(testthat) 2 | library(shinytest2) 3 | library(designer) 4 | 5 | test_check("designer") 6 | -------------------------------------------------------------------------------- /man/figures/example_app_filled.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashbaldry/designer/HEAD/man/figures/example_app_filled.jpeg -------------------------------------------------------------------------------- /inst/rstudio/addins.dcf: -------------------------------------------------------------------------------- 1 | Name: Shiny UI Builder 2 | Description: Design your shiny UI. 3 | Binding: designApp 4 | Interactive: true 5 | -------------------------------------------------------------------------------- /tests/testthat/test-template_utils.R: -------------------------------------------------------------------------------- 1 | test_that("create_random_id creates valid random ID", { 2 | expect_match(create_random_id(), "^[a-z]{10}$") 3 | }) 4 | -------------------------------------------------------------------------------- /srcjs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /inst/utils/golem-config.yml: -------------------------------------------------------------------------------- 1 | default: 2 | golem_name: designer 3 | golem_version: 0.1.0 4 | app_prod: no 5 | production: 6 | app_prod: yes 7 | dev: 8 | golem_wd: !expr here::here() 9 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/Page.test.js: -------------------------------------------------------------------------------- 1 | import { Page } from '../Page' 2 | 3 | test('sanity test - Page constructs successfully', () => { 4 | const page = new Page() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Box.test.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../Box' 2 | 3 | test('sanity test - box constructs successfully', () => { 4 | const component = new Box() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Row.test.js: -------------------------------------------------------------------------------- 1 | import { Row } from '../Row' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Row() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Tab.test.js: -------------------------------------------------------------------------------- 1 | import { Tab } from '../Tab' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Tab() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /inst/golem-config.yml: -------------------------------------------------------------------------------- 1 | default: 2 | golem_name: shinyexample 3 | golem_version: 0.0.0.9000 4 | app_prod: no 5 | 6 | production: 7 | app_prod: yes 8 | 9 | dev: 10 | golem_wd: !expr golem::pkg_path() 11 | 12 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Text.test.js: -------------------------------------------------------------------------------- 1 | import { Text } from '../Text' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Text() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/FillPage.test.js: -------------------------------------------------------------------------------- 1 | import { FillPage } from '../FillPage' 2 | 3 | test('sanity test - FillPage constructs successfully', () => { 4 | const page = new FillPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Button.test.js: -------------------------------------------------------------------------------- 1 | import { Button } from '../Button' 2 | 3 | test('sanity test - button constructs successfully', () => { 4 | const component = new Button() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Column.test.js: -------------------------------------------------------------------------------- 1 | import { Column } from '../Column' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Column() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Header.test.js: -------------------------------------------------------------------------------- 1 | import { Header } from '../Header' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Header() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Input.test.js: -------------------------------------------------------------------------------- 1 | import { Input } from '../Input' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Input() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Output.test.js: -------------------------------------------------------------------------------- 1 | import { Output } from '../Output' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Output() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Tabset.test.js: -------------------------------------------------------------------------------- 1 | import { Tabset } from '../Tabset' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new Tabset() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/BasicPage.test.js: -------------------------------------------------------------------------------- 1 | import { BasicPage } from '../BasicPage' 2 | 3 | test('sanity test - BasicPage constructs successfully', () => { 4 | const page = new BasicPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/FixedPage.test.js: -------------------------------------------------------------------------------- 1 | import { FixedPage } from '../FixedPage' 2 | 3 | test('sanity test - FixedPage constructs successfully', () => { 4 | const page = new FixedPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/FluidPage.test.js: -------------------------------------------------------------------------------- 1 | import { FluidPage } from '../FluidPage' 2 | 3 | test('sanity test - FluidPage constructs successfully', () => { 4 | const page = new FluidPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Callout.test.js: -------------------------------------------------------------------------------- 1 | import { Callout } from '../Callout' 2 | 3 | test('sanity test - callout constructs successfully', () => { 4 | const component = new Callout() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/InfoBox.test.js: -------------------------------------------------------------------------------- 1 | import { InfoBox } from '../InfoBox' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new InfoBox() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/UserBox.test.js: -------------------------------------------------------------------------------- 1 | import { UserBox } from '../UserBox' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new UserBox() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/NavbarPage.test.js: -------------------------------------------------------------------------------- 1 | import { NavbarPage } from '../NavbarPage' 2 | 3 | test('sanity test - NavbarPage constructs successfully', () => { 4 | const page = new NavbarPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /R/designer.R: -------------------------------------------------------------------------------- 1 | #' @description 2 | #' To learn more about \code{designer}, have a read of the vignette: \code{vignette("designer")} 3 | #' 4 | #' @import shiny 5 | #' @importFrom stats setNames 6 | #' 7 | #' @keywords internal 8 | "_PACKAGE" 9 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/ValueBox.test.js: -------------------------------------------------------------------------------- 1 | import { ValueBox } from '../ValueBox' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new ValueBox() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/DateInput.test.js: -------------------------------------------------------------------------------- 1 | import { DateInput } from '../DateInput' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new DateInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/FileInput.test.js: -------------------------------------------------------------------------------- 1 | import { FileInput } from '../FileInput' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new FileInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/BlockQuote.test.js: -------------------------------------------------------------------------------- 1 | import { BlockQuote } from '../BlockQuote' 2 | 3 | test('sanity test - block quote constructs successfully', () => { 4 | const component = new BlockQuote() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Checkbox.test.js: -------------------------------------------------------------------------------- 1 | import { CheckboxInput } from '../Checkbox' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new CheckboxInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/InputPanel.test.js: -------------------------------------------------------------------------------- 1 | import { InputPanel } from '../InputPanel' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new InputPanel() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/SelectInput.test.js: -------------------------------------------------------------------------------- 1 | import { SelectInput } from '../SelectInput' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new SelectInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/SliderInput.test.js: -------------------------------------------------------------------------------- 1 | import { SliderInput } from '../SliderInput' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new SliderInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/BootstrapPage.test.js: -------------------------------------------------------------------------------- 1 | import { BootstrapPage } from '../BootstrapPage' 2 | 3 | test('sanity test - BootstrapPage constructs successfully', () => { 4 | const page = new BootstrapPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/__tests__/DashboardPage.test.js: -------------------------------------------------------------------------------- 1 | import { DashboardPage } from '../DashboardPage' 2 | 3 | test('sanity test - DashboardPage constructs successfully', () => { 4 | const page = new DashboardPage() 5 | expect(page).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /srcjs/page/BasicPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class BasicPage extends Page { 4 | name = 'basicPage' 5 | enable_on_load = false 6 | page_html = '
' 7 | }; 8 | -------------------------------------------------------------------------------- /srcjs/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: standard 5 | overrides: [] 6 | parserOptions: 7 | ecmaVersion: latest 8 | sourceType: module 9 | rules: { 10 | "no-undef": "off", 11 | "camelcase": "off" 12 | } 13 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/CheckboxGroup.test.js: -------------------------------------------------------------------------------- 1 | import { CheckboxGroupInput } from '../CheckboxGroup' 2 | 3 | test('sanity test - checkbox constructs successfully', () => { 4 | const component = new CheckboxGroupInput() 5 | expect(component).toBeDefined() 6 | }) 7 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | export(designApp) 4 | import(shiny) 5 | importFrom(stats,setNames) 6 | importFrom(utils,packageVersion) 7 | importFrom(utils,read.csv) 8 | importFrom(utils,write.csv) 9 | importFrom(utils,write.table) 10 | -------------------------------------------------------------------------------- /srcjs/build.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild' 2 | 3 | build({ 4 | entryPoints: ['app/index.js'], 5 | bundle: true, 6 | sourcemap: true, 7 | outfile: '../inst/app/www/designer.min.js', 8 | platform: 'node', 9 | minify: true 10 | }).catch( 11 | () => process.exit(1) 12 | ) 13 | -------------------------------------------------------------------------------- /tests/testthat/test-ui-modules.R: -------------------------------------------------------------------------------- 1 | test_that("All Module UI generates with no errors", { 2 | module_ui_funcs <- ls(getNamespace("designer"), pattern = "ModUI") 3 | for (mod_ui_func in module_ui_funcs) { 4 | mod_ui <- get(mod_ui_func)(NULL) 5 | expect_type(mod_ui, "list") 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /srcjs/build_dev.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild' 2 | 3 | build({ 4 | entryPoints: ['app/index.js'], 5 | bundle: true, 6 | sourcemap: true, 7 | outfile: '../inst/app/www/designer.min.js', 8 | platform: 'node', 9 | minify: false 10 | }).catch( 11 | () => process.exit(1) 12 | ) 13 | -------------------------------------------------------------------------------- /app.R: -------------------------------------------------------------------------------- 1 | # Launch the ShinyApp (Do not remove this comment) 2 | # To deploy, run: rsconnect::deployApp() 3 | # Or use the blue button on top of this file 4 | 5 | pkgload::load_all( 6 | export_all = FALSE, 7 | helpers = FALSE, 8 | attach_testthat = FALSE 9 | ) 10 | 11 | options("golem.app.prod" = TRUE) 12 | designApp() 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .Rhistory 3 | .RData 4 | .Ruserdata 5 | .Rdata 6 | .httr-oauth 7 | .DS_Store 8 | dev 9 | # {shinytest2}: Ignore new debug snapshots for `$expect_values()` 10 | *_.new.png 11 | inst/doc 12 | /doc/ 13 | /Meta/ 14 | docs 15 | srcjs/node_modules 16 | shiny_bookmarks 17 | cran-comments.md 18 | CRAN-SUBMISSION 19 | srcjs/coverage 20 | -------------------------------------------------------------------------------- /R/mod_options_utils.R: -------------------------------------------------------------------------------- 1 | CSSFileInput <- function(id, label) { 2 | div( 3 | class = "form-group setting-input", 4 | tags$label(label), 5 | div( 6 | class = "custom-file", 7 | tags$input(id = id, type = "file", accept = ".css"), 8 | tags$label(class = "custom-file-label", `for` = id, "Choose file") 9 | ) 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /tests/testthat/test-template_server.R: -------------------------------------------------------------------------------- 1 | test_that("TemplateModuleServer loads successfully", { 2 | shiny::testServer( 3 | TemplateModuleServer, 4 | args = list( 5 | html = reactiveVal(), 6 | page = reactive("fluidPage") 7 | ), 8 | expr = { 9 | expect_error(session$setInputs(save_button = 1L), NA) 10 | } 11 | ) 12 | }) 13 | -------------------------------------------------------------------------------- /srcjs/page/FillPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class FillPage extends Page { 4 | name = 'fillPage' 5 | page_html = ` 6 |
9 | ` 10 | }; 11 | -------------------------------------------------------------------------------- /srcjs/page/BootstrapPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class BootstrapPage extends Page { 4 | name = 'bootstrapPage' 5 | page_html = ` 6 |
8 | ` 9 | }; 10 | -------------------------------------------------------------------------------- /srcjs/page/FixedPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class FixedPage extends Page { 4 | name = 'fixedPage' 5 | page_html = ` 6 |
9 | ` 10 | }; 11 | -------------------------------------------------------------------------------- /srcjs/page/FluidPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class FluidPage extends Page { 4 | name = 'fluidPage' 5 | page_html = ` 6 |
9 | ` 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/jest.yaml: -------------------------------------------------------------------------------- 1 | name: Run JS Tests (Jest) 2 | on: 3 | push: 4 | branches: [dev, main, master] 5 | pull_request: 6 | branches: [dev, main, master] 7 | defaults: 8 | run: 9 | working-directory: srcjs 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Install modules 16 | run: npm install 17 | - name: Run tests 18 | run: npm run test 19 | -------------------------------------------------------------------------------- /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^renv$ 2 | ^renv\.lock$ 3 | ^.*\.Rproj$ 4 | ^\.Rproj\.user$ 5 | ^dev$ 6 | ^\.github$ 7 | ^app\.R$ 8 | ^rsconnect$ 9 | _\.new\.png$ 10 | ^R/_disable_autoload\.R$ 11 | ^man/figures/example_app\.gif$ 12 | ^doc$ 13 | ^Meta$ 14 | ^_pkgdown\.yml$ 15 | ^docs$ 16 | ^pkgdown$ 17 | ^CRAN-RELEASE$ 18 | ^cran-comments\.md$ 19 | ^srcjs$ 20 | ^Dockerfile$ 21 | ^\.lintr$ 22 | ^shiny_bookmarks$ 23 | ^CRAN-SUBMISSION$ 24 | ^\.dockerignore$ 25 | ^manifest\.json$ 26 | -------------------------------------------------------------------------------- /srcjs/page/init.js: -------------------------------------------------------------------------------- 1 | import { selectPage, changePageCheck, createPage, updateTitle, revertPageSelection } from './utils' 2 | 3 | export function initPage () { 4 | createPage() 5 | $('.canvas-page-choice').on('click', selectPage) 6 | $('#settings-page_type').on('change', changePageCheck) 7 | $('#cancel_reset').on('click', revertPageSelection) 8 | $('#confirm_reset').on('click', createPage) 9 | 10 | $('#app_name').on('change keyup', updateTitle) 11 | }; 12 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { getComponent } from '../utils' 2 | import { Header } from '../Header' 3 | import { Box } from '../Box' 4 | 5 | test('getComponent by default returns a header', () => { 6 | expect(getComponent()).toBeInstanceOf(Header) 7 | }) 8 | 9 | test('getComponent returns correctly specified component', () => { 10 | expect(getComponent('header')).toBeInstanceOf(Header) 11 | expect(getComponent('box')).toBeInstanceOf(Box) 12 | }) 13 | -------------------------------------------------------------------------------- /designer.Rproj: -------------------------------------------------------------------------------- 1 | Version: 1.0 2 | 3 | RestoreWorkspace: Default 4 | SaveWorkspace: Default 5 | AlwaysSaveHistory: Default 6 | 7 | EnableCodeIndexing: Yes 8 | UseSpacesForTab: Yes 9 | NumSpacesForTab: 2 10 | Encoding: UTF-8 11 | 12 | RnwWeave: Sweave 13 | LaTeX: pdfLaTeX 14 | 15 | AutoAppendNewline: Yes 16 | StripTrailingWhitespace: Yes 17 | 18 | BuildType: Package 19 | PackageUseDevtools: Yes 20 | PackageInstallArgs: --no-multiarch --with-keep.source 21 | PackageRoxygenize: rd,collate,namespace,vignette 22 | -------------------------------------------------------------------------------- /rsconnect/shinyapps.io/ashbaldry/designer.dcf: -------------------------------------------------------------------------------- 1 | name: designer 2 | title: designer 3 | username: 4 | account: ashbaldry 5 | server: shinyapps.io 6 | hostUrl: https://api.shinyapps.io/v1 7 | appId: 6009870 8 | bundleId: 6715028 9 | url: https://ashbaldry.shinyapps.io/designer/ 10 | when: 1673300635.97561 11 | lastSyncTime: 1673300635.97562 12 | asMultiple: FALSE 13 | asStatic: FALSE 14 | ignoredFiles: .lintr|.Rbuildignore|_pkgdown.yml|Dockerfile|NEWS.md|README.md|.github|dev|shiny_bookmarks|srcjs|tests|vignettes 15 | -------------------------------------------------------------------------------- /srcjs/component/Header.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Header extends Component { 4 | html = '<$tag$ class="designer-element" data-shinyfunction="$tag$">$value$' 5 | 6 | constructor () { 7 | super() 8 | this.updateComponent(true) 9 | } 10 | 11 | createComponent () { 12 | const tag = $('#sidebar-header-tag').val() 13 | const value = $('#sidebar-header-text').val() 14 | return this.replaceHTMLPlaceholders(this.html, { tag, value }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /R/mod_canvas_srv.R: -------------------------------------------------------------------------------- 1 | #' Canvas Server Functions 2 | #' 3 | #' @importFrom utils packageVersion 4 | #' @noRd 5 | CanvasModuleServer <- function(id, selected_template) { 6 | moduleServer(id, function(input, output, session) { 7 | setBookmarkExclude(c("html", "canvas", "screenshot")) 8 | 9 | observeEvent(selected_template(), { 10 | session$sendInputMessage("html", selected_template()) 11 | }) 12 | 13 | return(list( 14 | ui_code = reactive(input$canvas), 15 | html = reactive(input$html) 16 | )) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /tests/testthat/test-golem-recommended.R: -------------------------------------------------------------------------------- 1 | test_that("app ui", { 2 | ui <- appUI() 3 | golem::expect_shinytaglist(ui) 4 | # Check that formals have not been removed 5 | fmls <- formals(appUI) 6 | for (i in "request") { 7 | expect_true(i %in% names(fmls)) 8 | } 9 | }) 10 | 11 | test_that("app server", { 12 | server <- appServer 13 | expect_type(server, "closure") 14 | # Check that formals have not been removed 15 | fmls <- formals(appServer) 16 | for (i in c("input", "output", "session")) { 17 | expect_true(i %in% names(fmls)) 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /srcjs/app/screenshot.js: -------------------------------------------------------------------------------- 1 | export function screenshotSettings () { 2 | Shiny.addCustomMessageHandler('prepare_canvas_screenshot', (message) => { 3 | $('.designer-page-template').addClass('hidden-after-label') 4 | $('.designer-page-template').addClass('hidden-colour') 5 | $('.designer-page-template').addClass('hidden-borders') 6 | }) 7 | 8 | Shiny.addCustomMessageHandler('revert_canvas_screenshot', (message) => { 9 | $('#remove_label').trigger('change') 10 | $('#remove_colour').trigger('change') 11 | $('#remove_border').trigger('change') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /srcjs/component/Row.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Row extends Component { 4 | updatable = false 5 | html = '
' 6 | 7 | constructor (update_component = true) { 8 | super() 9 | 10 | if (update_component) { 11 | this.updateComponent(true) 12 | } 13 | } 14 | 15 | sortable_settings = { 16 | group: { 17 | name: 'shared', 18 | put: function (_to, _from, clone) { 19 | return clone.classList.contains('col-sm') 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/testthat/test-cache.R: -------------------------------------------------------------------------------- 1 | test_that("finding the cache directory creates one if necessary", { 2 | cache_dir <- find_cache_dir() 3 | expect_true(dir.exists(cache_dir)) 4 | }) 5 | 6 | test_that("cache dir can use custom directory", { 7 | temp_dir <- tempdir() 8 | good_dir <- file.path(temp_dir, "good_dir") 9 | dir.create(good_dir) 10 | Sys.setenv(R_DESIGNER_CACHE = good_dir) 11 | 12 | on.exit({ 13 | Sys.setenv(R_DESIGNER_CACHE = "") 14 | unlink(temp_dir, recursive = TRUE, force = TRUE) 15 | }) 16 | 17 | cache_dir <- find_cache_dir() 18 | expect_identical(cache_dir, good_dir) 19 | }) 20 | -------------------------------------------------------------------------------- /srcjs/component/BlockQuote.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class BlockQuote extends Component { 4 | html = '
$value$
' 5 | 6 | constructor () { 7 | super() 8 | this.updateComponent(true) 9 | } 10 | 11 | createComponent () { 12 | const colour = $('#sidebar-quote-colour').val() 13 | const value = $('#sidebar-quote-textarea').val() 14 | 15 | return this.replaceHTMLPlaceholders(this.html, { colour, value }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /srcjs/input/canvas-page-input.js: -------------------------------------------------------------------------------- 1 | import { htmlToJSON } from './utils' 2 | 3 | export const canvasPageBinding = new Shiny.InputBinding() 4 | 5 | $.extend(canvasPageBinding, { 6 | find: function (scope) { 7 | return $(scope).find('.page-canvas') 8 | }, 9 | getValue: function (el) { 10 | return htmlToJSON(document.getElementById('canvas-page')) 11 | }, 12 | subscribe: function (el, callback) { 13 | const observer = new MutationObserver(function () { callback() }) 14 | observer.observe(el, { subtree: true, childList: true, attributes: true }) 15 | }, 16 | unsubscribe: function (el) { 17 | $(el).off('.page-canvas') 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /srcjs/component/InputPanel.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class InputPanel extends Component { 4 | updatable = false 5 | html = '
' 6 | 7 | constructor (update_component = true) { 8 | super() 9 | 10 | if (update_component) { 11 | this.updateComponent(true) 12 | } 13 | } 14 | 15 | sortable_settings = { 16 | group: { 17 | name: 'shared', 18 | put: function (_to, _from, clone) { 19 | return clone.classList.contains('form-group') || clone.classList.contains('btn') 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /R/mod_canvas_utils.R: -------------------------------------------------------------------------------- 1 | safari_circle <- function(cx, fill, stroke) { 2 | tags$circle( 3 | cx = cx, 4 | cy = "5", 5 | r = "6", 6 | fill = fill, 7 | stroke = stroke, 8 | `stroke-width` = ".5" 9 | ) 10 | } 11 | 12 | createPageItem <- function(page_index) { 13 | page_type <- names(PAGE_TYPES)[page_index] 14 | page_id <- unname(PAGE_TYPES[page_index]) 15 | page_desc <- PAGE_DESCRIPTIONS[page_type] 16 | 17 | tags$button( 18 | class = "btn btn-secondary canvas-page-choice", 19 | type = "button", 20 | `data-page` = page_id, 21 | tags$h4(class = "canvas-choice-header", page_type), 22 | tags$p(class = "canvas-choice-content", page_desc) 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /R/app_server.R: -------------------------------------------------------------------------------- 1 | #' The application server-side 2 | #' 3 | #' @param input,output,session Internal parameters for {shiny}. 4 | #' 5 | #' @noRd 6 | appServer <- function(input, output, session) { 7 | setBookmarkExclude(c( 8 | "app_name", "remove_border", "remove_label", "remove_colour", "help", "css_style", "screenshot" 9 | )) 10 | 11 | observeEvent(input$help, guide$init()$start()) 12 | 13 | template_html <- reactiveVal() 14 | 15 | page_html <- CanvasModuleServer("canvas", selected_template = template_html) 16 | 17 | selected_template <- SettingsModuleServer("settings", ui_code = page_html) 18 | 19 | observeEvent(selected_template(), template_html(selected_template())) 20 | 21 | SidebarModuleServer("sidebar") 22 | } 23 | -------------------------------------------------------------------------------- /.lintr: -------------------------------------------------------------------------------- 1 | linters: linters_with_defaults( 2 | cyclocomp_linter = NULL, 3 | line_length_linter(120), 4 | implicit_integer_linter(), 5 | object_name_linter(c("snake_case", "camelCase", "CamelCase", "SNAKE_CASE", "symbols")), 6 | any_duplicated_linter(), 7 | any_is_na_linter(), 8 | class_equals_linter(), 9 | condition_message_linter(), 10 | conjunct_test_linter(), 11 | duplicate_argument_linter(), 12 | expect_comparison_linter(), 13 | expect_length_linter(), 14 | expect_length_linter(), 15 | expect_not_linter(), 16 | expect_null_linter(), 17 | redundant_ifelse_linter(), 18 | undesirable_operator_linter(), 19 | unneeded_concatenation_linter(), 20 | unreachable_code_linter(), 21 | yoda_test_linter() 22 | ) 23 | -------------------------------------------------------------------------------- /man/component.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/mod_sidebar_components.R 3 | \name{component} 4 | \alias{component} 5 | \alias{componentTab} 6 | \title{Component Settings Shell} 7 | \usage{ 8 | component(id, ...) 9 | 10 | componentTab(id) 11 | } 12 | \arguments{ 13 | \item{id}{The ID of the component input} 14 | 15 | \item{...}{Shiny tags to include inside the component} 16 | } 17 | \value{ 18 | A shiny.tag of the component settings 19 | } 20 | \description{ 21 | A container for the specified component input 22 | } 23 | \details{ 24 | The tab component contains a selection of specific inputs related to adding a new tab, as 25 | the events to create it in the UI are different to the other components 26 | } 27 | -------------------------------------------------------------------------------- /srcjs/component/Text.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Text extends Component { 4 | html = '<$tag$ class="designer-element" data-shinyfunction="tags$$tag$">$value$' 5 | 6 | constructor () { 7 | super() 8 | this.updateComponent(true) 9 | } 10 | 11 | createComponent () { 12 | const tag = $('#sidebar-text-tag').val() 13 | const value = $('#sidebar-text-textarea').val() 14 | const contents = tag === 'p' ? value.replace(/\n/g, ' ') : this.createListItems(value) 15 | 16 | return this.replaceHTMLPlaceholders(this.html, { tag, value: contents }) 17 | } 18 | 19 | createListItems (text) { 20 | return text.split('\n').map(x => '
  • ' + x + '
  • ').join('') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /man/designer-package.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/designer.R 3 | \docType{package} 4 | \name{designer-package} 5 | \alias{designer} 6 | \alias{designer-package} 7 | \title{designer: 'Shiny' UI Prototype Builder} 8 | \description{ 9 | To learn more about \code{designer}, have a read of the vignette: \code{vignette("designer")} 10 | } 11 | \seealso{ 12 | Useful links: 13 | \itemize{ 14 | \item \url{https://ashbaldry.github.io/designer/} 15 | \item \url{https://ashbaldry.shinyapps.io/designer/} 16 | \item Report bugs at \url{https://github.com/ashbaldry/designer/issues} 17 | } 18 | 19 | } 20 | \author{ 21 | \strong{Maintainer}: Ashley Baldry \email{arbaldry91@gmail.com} 22 | 23 | Other contributors: 24 | \itemize{ 25 | \item Sam Parmar \email{parmartsam@gmail.com} [contributor] 26 | } 27 | 28 | } 29 | \keyword{internal} 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples 2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help 3 | on: 4 | push: 5 | branches: [dev, main, master] 6 | pull_request: 7 | branches: [dev, main, master] 8 | 9 | name: lint 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | env: 15 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: r-lib/actions/setup-r@v2 20 | with: 21 | use-public-rspm: true 22 | 23 | - uses: r-lib/actions/setup-r-dependencies@v2 24 | with: 25 | extra-packages: any::lintr, local::. 26 | needs: lint 27 | 28 | - name: Lint 29 | run: lintr::lint_package() 30 | shell: Rscript {0} 31 | env: 32 | LINTR_ERROR_ON_LINT: true 33 | -------------------------------------------------------------------------------- /srcjs/component/init.js: -------------------------------------------------------------------------------- 1 | import { getComponent } from './utils' 2 | import { component } from './Component' 3 | 4 | export function initComponents () { 5 | getComponent('header') 6 | 7 | $('.component-settings').on('change keyup', () => component.updateComponent()) 8 | $('.component-comments').on('change blur', () => component.updateComponent()) 9 | $('.component-container').on('mouseover', () => { $(':focus').trigger('blur') }) 10 | 11 | $('.add-tab-button').on('click', () => component.addPage()) 12 | $('.delete-tab-button').on('click', () => component.deletePage()) 13 | 14 | $('.accordion .card-header .btn').on('click', (el) => { 15 | $(el.target).closest('.card').find('form').trigger('reset') 16 | 17 | const new_component = $(el.target).data('shinyelement') 18 | getComponent(new_component) 19 | document.getElementById('sidebar-container').style.display = new_component === 'tab_panel' ? 'none' : null 20 | }) 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/r-lib/actions/tree/master/examples 2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help 3 | on: 4 | push: 5 | branches: [dev, main, master] 6 | pull_request: 7 | branches: [dev, main, master] 8 | 9 | name: test-coverage 10 | 11 | jobs: 12 | test-coverage: 13 | runs-on: ubuntu-latest 14 | env: 15 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - uses: r-lib/actions/setup-pandoc@v2 21 | 22 | - uses: r-lib/actions/setup-r@v2 23 | with: 24 | http-user-agent: ${{ matrix.config.http-user-agent }} 25 | use-public-rspm: true 26 | 27 | - uses: r-lib/actions/setup-r-dependencies@v2 28 | with: 29 | extra-packages: covr 30 | 31 | - name: Test coverage 32 | run: covr::codecov() 33 | shell: Rscript {0} 34 | -------------------------------------------------------------------------------- /srcjs/app/index.js: -------------------------------------------------------------------------------- 1 | import { initPage } from '../page/init' 2 | import { initSettings } from './settings' 3 | import { screenshotSettings } from './screenshot' 4 | import { initComponents } from '../component/init' 5 | import { canvasPageBinding } from '../input/canvas-page-input' 6 | import { canvasBinding } from '../input/canvas-canvas-input' 7 | 8 | $(function () { 9 | initPage() 10 | initComponents() 11 | initSettings() 12 | screenshotSettings() 13 | 14 | $('.help-icon').tooltip({ boundary: 'window', placement: 'right' }) 15 | 16 | bsCustomFileInput.init() 17 | 18 | Sortable.create(document.getElementById('sidebar-bin'), { 19 | group: { 20 | name: 'shared', 21 | pull: false 22 | }, 23 | handle: '.designer-element', 24 | draggable: '.designer-element', 25 | onAdd: function (evt) { 26 | this.el.removeChild(evt.item) 27 | } 28 | }) 29 | }) 30 | 31 | Shiny.inputBindings.register(canvasPageBinding) 32 | Shiny.inputBindings.register(canvasBinding) 33 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # `{designer}` 2 | 3 | ## 0.2.0 4 | 5 | ### Features 6 | 7 | * {bs4Dash} page and components added to the available options (#31) 8 | 9 | * Elements can be deleted by right clicking an element and deleting (#35) 10 | 11 | * Page choice is available on application start rather than choosing in the dropdown (#48) 12 | 13 | * JavaScript code has moved to separate folder to work on as a module rather than 2/3 large files 14 | 15 | ### UX Improvements 16 | 17 | * Reduced size of component settings to prevent scrolling when editing a component (#49) 18 | 19 | * Basic inputs have been split out into numeric, text, text input and password (#60) 20 | 21 | ### Bug Fixes 22 | 23 | * Fixed rendering issue with radio buttons and grouped checkboxes (#26) 24 | 25 | * Button class now appears in the code (#28) 26 | 27 | * Warning about duplicate tab names has been removed (#37) 28 | 29 | * Close and snapshot buttons are visible on full page preview for navbar page 30 | 31 | ## 0.1.0 32 | 33 | * Initial package release 34 | -------------------------------------------------------------------------------- /srcjs/page/NavbarPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class NavbarPage extends Page { 4 | name = 'navbarPage' 5 | navbar_item_style = '' 6 | enable_on_load = false 7 | page_html = ` 8 |
    9 | 17 | 22 |
    23 | ` 24 | }; 25 | -------------------------------------------------------------------------------- /man/sidebarItem.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/mod_sidebar_utils.R 3 | \name{sidebarItem} 4 | \alias{sidebarItem} 5 | \title{Component Accordion Item} 6 | \usage{ 7 | sidebarItem(id, name, element, parent_id, ..., notes = NULL, active = FALSE) 8 | } 9 | \arguments{ 10 | \item{id}{HTML ID to namespace on} 11 | 12 | \item{name}{Label to show on the closed accordion} 13 | 14 | \item{element}{Character string to let JS know what component has been chosen} 15 | 16 | \item{parent_id}{HTML ID of the accordion} 17 | 18 | \item{...}{Option inputs to add when expanding the accordion item} 19 | 20 | \item{notes}{A list of optional notes to include at the bottom of the settings} 21 | 22 | \item{active}{Logical, should the accordion item be open on start? Default set to \code{FALSE}} 23 | } 24 | \value{ 25 | A \code{shiny.tag} element containing the component accordion item with all input settings 26 | } 27 | \description{ 28 | An item to add to the sidebar that opens up the settings for the selected component 29 | } 30 | -------------------------------------------------------------------------------- /R/run_app.R: -------------------------------------------------------------------------------- 1 | #' Run the Shiny Application 2 | #' 3 | #' @description 4 | #' Runs the designer Shiny application. 5 | #' 6 | #' For more information about how the application works, either run the "Help" guide in-app, or run 7 | #' \code{vignette("designer")}. 8 | #' 9 | #' @param ... arguments to pass to \code{golem_opts}. See \code{\link[golem]{get_golem_options}} for more details. 10 | #' @inheritParams shiny::shinyApp 11 | #' 12 | #' @return 13 | #' This function does not return a value; interrupt R to stop the application (usually by pressing Ctrl+C or Esc). 14 | #' 15 | #' @examplesIf interactive() 16 | #' designApp() 17 | #' 18 | #' @export 19 | designApp <- function(onStart = NULL, options = list(), enableBookmarking = "url", uiPattern = "/", ...) { 20 | golem::with_golem_options( 21 | app = shinyApp( 22 | ui = appUI, 23 | server = appServer, 24 | onStart = onStart, 25 | options = options, 26 | enableBookmarking = enableBookmarking, 27 | uiPattern = uiPattern 28 | ), 29 | golem_opts = list(...) 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /srcjs/component/Column.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Column extends Component { 4 | updatable = false 5 | html = '
    ' 6 | sortable_settings = { 7 | group: { 8 | name: 'shared', 9 | put: function (_to, _from, clone) { 10 | return !clone.classList.contains('col-sm') 11 | } 12 | } 13 | } 14 | 15 | constructor (update_component = true) { 16 | super() 17 | 18 | if (update_component) { 19 | this.updateComponent(true) 20 | } 21 | } 22 | 23 | createComponent () { 24 | const width = $('#sidebar-column-width_num').val() 25 | const offset = $('#sidebar-column-offset').val() 26 | 27 | const offset_class = offset > 0 ? ` offset-md-${offset}` : '' 28 | const offset_r = offset > 0 ? `, offset = ${offset}` : '' 29 | 30 | return this.replaceHTMLPlaceholders(this.html, { width, offset_class, offset_r }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /srcjs/README.md: -------------------------------------------------------------------------------- 1 | # Desinger JS 2 | 3 | Turning into a module for much easier addition of new components/pages 4 | 5 | ## First-time setup 6 | The JavaScript component of designer uses Node.js build tools, along with [yarn](https://yarnpkg.com/) v2 to manage the JavaScript packages. 7 | 8 | Installation of Node.js differs across platforms, see [the official Node.js website](https://nodejs.org/) for instructions on downloading and installing. We presume that you have Node.js installed on your machine before continuing. 9 | 10 | Install yarn using the [official instructions](https://yarnpkg.com/en/docs/install). 11 | 12 | You can test that Node.js and yarn are installed properly by running the following commands: 13 | 14 | ```bash 15 | node --version 16 | yarn --version 17 | ``` 18 | 19 | Once both are installed, run the following in the `srcjs` repo directory to install the packages : 20 | 21 | ```bash 22 | # Sitting in `designer/srcjs` directory 23 | yarn install 24 | ``` 25 | 26 | ## Contributing 27 | 28 | If any JS is changed, run `yarn build` to add the minified JS to the application. 29 | -------------------------------------------------------------------------------- /srcjs/component/Callout.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Callout extends Component { 4 | html = ` 5 |
    6 |
    8 |
    $title$
    9 | $value$ 10 |
    11 | ` 12 | constructor () { 13 | super() 14 | this.updateComponent(true) 15 | } 16 | 17 | createComponent () { 18 | const title = $('#sidebar-callout-label').val() 19 | const status = $('#sidebar-callout-colour').val() 20 | const value = $('#sidebar-callout-textarea').val() 21 | 22 | const width = $('#sidebar-callout-width_num').val() 23 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 24 | const width_r = width > 0 ? width : 'NULL' 25 | 26 | return this.replaceHTMLPlaceholders(this.html, { 27 | title, 28 | status, 29 | value, 30 | width_r, 31 | width_class 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /inst/srcjs/sortable/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 All contributors to Sortable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /srcjs/input/utils.js: -------------------------------------------------------------------------------- 1 | export function htmlToJSON (el, inner = false) { 2 | const children = getChildrenJSON(el) 3 | 4 | const jsonElement = { 5 | tagName: el.tagName.toLowerCase(), 6 | r_function: el.dataset.shinyfunction, 7 | r_arguments: el.dataset.shinyattributes, 8 | r_comments: el.dataset.shinycomments, 9 | text: $(el).ignore().text().replace(/\s*\n\s*/g, ''), 10 | htmlclass: el.className, 11 | children 12 | } 13 | 14 | if (inner) { 15 | return jsonElement 16 | } else { 17 | return JSON.stringify(jsonElement) 18 | } 19 | }; 20 | 21 | $.fn.ignore = function (sel) { 22 | return this.clone().find(sel || '>*').remove().end() 23 | } 24 | 25 | function getChildrenJSON (el) { 26 | let children = [] 27 | for (let i = 0; i < el.children.length; i++) { 28 | if (el.children[i].dataset.shinyfunction) { 29 | children.push(htmlToJSON(el.children[i], true)) 30 | } else if (el.children[i].children.length) { 31 | const childContent = getChildrenJSON(el.children[i]) 32 | if (childContent.length > 0) { 33 | children = children.concat(childContent) 34 | } 35 | } 36 | } 37 | return children 38 | }; 39 | -------------------------------------------------------------------------------- /R/app_config.R: -------------------------------------------------------------------------------- 1 | #' Access files in the current app 2 | #' 3 | #' NOTE: If you manually change your package name in the DESCRIPTION, 4 | #' don't forget to change it here too, and in the config file. 5 | #' For a safer name change mechanism, use the `golem::set_golem_name()` function. 6 | #' 7 | #' @param ... character vectors, specifying subdirectory and file(s) 8 | #' within your package. The default, none, returns the root of the app. 9 | #' 10 | #' @noRd 11 | appSys <- function(...) { 12 | system.file(..., package = "designer") 13 | } 14 | 15 | #' Read App Config 16 | #' 17 | #' @param value Value to retrieve from the config file. 18 | #' @param config GOLEM_CONFIG_ACTIVE value. If unset, R_CONFIG_ACTIVE. 19 | #' If unset, "default". 20 | #' @param use_parent Logical, scan the parent directory for config file. 21 | #' 22 | #' @noRd 23 | getGolemConfig <- function(value, 24 | config = Sys.getenv("GOLEM_CONFIG_ACTIVE", Sys.getenv("R_CONFIG_ACTIVE", "default")), 25 | use_parent = TRUE) { 26 | config::get( 27 | value = value, 28 | config = config, 29 | file = appSys("utils/golem-config.yml"), 30 | use_parent = use_parent 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: designer 2 | Title: 'Shiny' UI Prototype Builder 3 | Version: 0.3.0 4 | Authors@R: c( 5 | person("Ashley", "Baldry", , "arbaldry91@gmail.com", role = c("cre", "aut")), 6 | person("Sam", "Parmar", , "parmartsam@gmail.com", role = "ctb") 7 | ) 8 | Description: A 'shiny' application that enables the user to create a 9 | prototype UI, being able to drag and drop UI components before being 10 | able to save or download the equivalent R code. 11 | License: MIT + file LICENSE 12 | URL: https://ashbaldry.github.io/designer/, 13 | https://ashbaldry.shinyapps.io/designer/ 14 | BugReports: https://github.com/ashbaldry/designer/issues 15 | Imports: 16 | bs4Dash, 17 | bslib, 18 | cicerone, 19 | config (>= 0.3.1), 20 | fontawesome, 21 | golem (>= 0.3.1), 22 | htmltools, 23 | jsonlite, 24 | rappdirs, 25 | shinipsum, 26 | shiny (>= 1.7.1), 27 | shinyscreenshot 28 | Suggests: 29 | chromote, 30 | knitr, 31 | lintr, 32 | rmarkdown, 33 | shinytest2, 34 | testthat (>= 3.0.0) 35 | VignetteBuilder: 36 | knitr 37 | Config/testthat/edition: 3 38 | Encoding: UTF-8 39 | Language: en-GB 40 | Roxygen: list(markdown = TRUE) 41 | RoxygenNote: 7.2.3 42 | -------------------------------------------------------------------------------- /srcjs/component/Checkbox.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class CheckboxInput extends Component { 4 | html = ` 5 |
    8 |
    9 | 12 |
    13 |
    14 | ` 15 | 16 | constructor () { 17 | super() 18 | this.updateComponent(true) 19 | } 20 | 21 | createComponent () { 22 | const label = $('#sidebar-checkbox-label').val() 23 | 24 | let id = $('#sidebar-checkbox-id').val() 25 | id = id === '' ? this.createID('checkbox') : id 26 | 27 | const width = this.validateCssUnit($('#sidebar-checkbox-width').val()) 28 | const style_str = width ? `style="width: ${width};"` : '' 29 | const width_str = width ? `, width = "${width}"` : '' 30 | 31 | return this.replaceHTMLPlaceholders(this.html, { 32 | id, 33 | label, 34 | style_str, 35 | width_str 36 | }) 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: dev 6 | release: 7 | types: published 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Log in to Docker Hub 17 | uses: docker/login-action@v2 18 | with: 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_TOKEN }} 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | 25 | - name: Build and push 26 | if: ${{ github.event_name == 'push' }} 27 | uses: docker/build-push-action@v4 28 | with: 29 | context: . 30 | file: ./Dockerfile 31 | push: true 32 | tags: ${{ secrets.DOCKER_USERNAME }}/designer:latest 33 | 34 | - name: Build and push 35 | if: ${{ github.event_name == 'release' }} 36 | uses: docker/build-push-action@v4 37 | with: 38 | context: . 39 | file: ./Dockerfile 40 | push: true 41 | tags: ${{ secrets.DOCKER_USERNAME }}/designer:${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /srcjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "designer", 4 | "version": "0.3.0", 5 | "main": "app/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "node build.js", 9 | "build_dev": "node build_dev.js", 10 | "lint": "eslint --fix --ext .js,.jsx .", 11 | "test": "jest", 12 | "coverage": "jest --coverage" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/ashbaldry/designer.git" 17 | }, 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/ashbaldry/designer/issues" 21 | }, 22 | "homepage": "https://ashbaldry.github.com/designer", 23 | "dependencies": { 24 | "@types/rstudio-shiny": "git+https://github.com/rstudio/shiny.git#v1.7.4", 25 | "jquery": "^3.5.14", 26 | "sortablejs": "^1.15.0" 27 | }, 28 | "devDependencies": { 29 | "@babel/plugin-transform-modules-commonjs": "^7.19.6", 30 | "esbuild": "^0.14.54", 31 | "eslint": "^8.34.0", 32 | "eslint-config-standard": "^17.0.0", 33 | "eslint-plugin-import": "^2.25.2", 34 | "eslint-plugin-n": "^15.0.0", 35 | "eslint-plugin-promise": "^6.0.0", 36 | "jest": "^29.4.3", 37 | "jest-environment-jsdom": "^29.3.1" 38 | } 39 | } -------------------------------------------------------------------------------- /.github/workflows/pkgdown.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples 2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help 3 | on: 4 | push: 5 | branches: [dev, main, master] 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | 10 | name: pkgdown 11 | 12 | jobs: 13 | pkgdown: 14 | runs-on: ubuntu-latest 15 | # Only restrict concurrency for non-PR jobs 16 | concurrency: 17 | group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} 18 | env: 19 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - uses: r-lib/actions/setup-pandoc@v2 24 | 25 | - uses: r-lib/actions/setup-r@v2 26 | with: 27 | use-public-rspm: true 28 | 29 | - uses: r-lib/actions/setup-r-dependencies@v2 30 | with: 31 | extra-packages: any::pkgdown, local::. 32 | needs: website 33 | 34 | - name: Build site 35 | run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) 36 | shell: Rscript {0} 37 | 38 | - name: Deploy to GitHub pages 🚀 39 | if: github.event_name != 'pull_request' 40 | uses: JamesIves/github-pages-deploy-action@v4.4.1 41 | with: 42 | clean: false 43 | branch: gh-pages 44 | folder: docs 45 | -------------------------------------------------------------------------------- /R/mod_options_ui.R: -------------------------------------------------------------------------------- 1 | OptionsModUI <- function(id) { 2 | ns <- NS(id) 3 | 4 | tagList( 5 | tags$form( 6 | class = "px-2", 7 | tagAppendAttributes( 8 | textInput( 9 | ns("app_name"), 10 | label = "Application Title", 11 | value = "Shiny Application", 12 | width = "100%" 13 | ), 14 | class = "setting-input" 15 | ), 16 | CSSFileInput( 17 | ns("css_style"), 18 | label = "Apply CSS Style" 19 | ), 20 | checkboxInput( 21 | ns("remove_label"), 22 | label = "Show Component Labels", 23 | value = TRUE 24 | ), 25 | checkboxInput( 26 | ns("remove_colour"), 27 | label = "Show Colour Background", 28 | value = TRUE 29 | ), 30 | checkboxInput( 31 | ns("remove_border"), 32 | label = "Show Borders", 33 | value = TRUE 34 | ), 35 | tags$button( 36 | id = ns("preview"), 37 | type = "button", 38 | class = "btn btn-secondary btn-block", 39 | "Preview Full Page" 40 | ), 41 | screenshtButton( 42 | btn_id = ns("screenshot"), 43 | class = "btn-secondary btn-block", 44 | scale = 1.5 45 | ), 46 | tags$button( 47 | id = ns("canvas_clear"), 48 | type = "button", 49 | class = "btn btn-danger btn-block", 50 | "Clear Page" 51 | ) 52 | ) 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /R/mod_settings_ui.R: -------------------------------------------------------------------------------- 1 | #' Canvas UI Function 2 | #' 3 | #' @description A shiny Module. 4 | #' 5 | #' @param id,input,output,session Internal parameters for {shiny}. 6 | #' 7 | #' @noRd 8 | SettingsModUI <- function(id) { 9 | ns <- NS(id) 10 | 11 | div( 12 | class = "row settings-row row-cols-4", 13 | settingsDropdownButton( 14 | id = ns("page_type_button"), 15 | label = "Page Type", 16 | contents = pageOptions(ns) 17 | ), 18 | settingsDropdownButton( 19 | id = ns("code_button"), 20 | label = "Code", 21 | contents = div( 22 | id = ns("code_dropdown"), 23 | `aria-labelledby` = ns("code_button"), 24 | class = "dropdown-menu dropdown-menu-wide clickable-dropdown", 25 | CodeModUI(ns("code")) 26 | ) 27 | ), 28 | settingsDropdownButton( 29 | id = ns("template_button"), 30 | label = "Templates", 31 | contents = div( 32 | id = ns("template_dropdown"), 33 | `aria-labelledby` = ns("template_button"), 34 | class = "dropdown-menu dropdown-menu-wide clickable-dropdown", 35 | TemplateModUI(ns("template")) 36 | ) 37 | ), 38 | settingsDropdownButton( 39 | id = ns("options_button"), 40 | label = "Settings", 41 | contents = div( 42 | id = ns("options_dropdown"), 43 | `aria-labelledby` = ns("options_button"), 44 | class = "dropdown-menu dropdown-menu-wide page-type-dropdown clickable-dropdown", 45 | OptionsModUI(NULL) 46 | ) 47 | ) 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/R-CMD-check.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples 2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help 3 | on: 4 | push: 5 | branches: [dev, main, master] 6 | pull_request: 7 | branches: [dev, main, master] 8 | 9 | name: R-CMD-check 10 | 11 | jobs: 12 | R-CMD-check: 13 | runs-on: ${{ matrix.config.os }} 14 | 15 | name: ${{ matrix.config.os }} (${{ matrix.config.r }}) 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | config: 21 | - {os: macos-latest, r: 'release'} 22 | - {os: windows-latest, r: 'release'} 23 | - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} 24 | - {os: ubuntu-latest, r: 'release'} 25 | - {os: ubuntu-latest, r: 'oldrel-1'} 26 | 27 | env: 28 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 29 | R_KEEP_PKG_SOURCE: yes 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | 34 | - uses: r-lib/actions/setup-pandoc@v2 35 | 36 | - uses: r-lib/actions/setup-r@v2 37 | with: 38 | r-version: ${{ matrix.config.r }} 39 | http-user-agent: ${{ matrix.config.http-user-agent }} 40 | use-public-rspm: true 41 | 42 | - uses: r-lib/actions/setup-r-dependencies@v2 43 | with: 44 | extra-packages: any::rcmdcheck 45 | needs: check 46 | 47 | - uses: r-lib/actions/check-r-package@v2 48 | with: 49 | upload-snapshots: true 50 | -------------------------------------------------------------------------------- /srcjs/component/SelectInput.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class SelectInput extends Component { 4 | html = ` 5 |
    8 | 9 |
    10 | 14 | 15 | 16 | 17 |
    18 |
    19 | ` 20 | 21 | constructor () { 22 | super() 23 | this.updateComponent(true) 24 | } 25 | 26 | createComponent () { 27 | const label = $('#sidebar-file-label').val() 28 | 29 | let id = $('#sidebar-file-id').val() 30 | id = id === '' ? this.createID('input') : id 31 | 32 | const width = this.validateCssUnit($('#sidebar-file-width').val()) 33 | const style_str = width ? `style="width: ${width};"` : '' 34 | const width_str = width ? `, width = "${width}"` : '' 35 | 36 | return this.replaceHTMLPlaceholders(this.html, { 37 | id, 38 | label, 39 | style_str, 40 | width_str 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /srcjs/page/Page.js: -------------------------------------------------------------------------------- 1 | export class Page { 2 | name 3 | navbar_item_style = 'none' 4 | bs4_item_style = 'none' 5 | enable_on_load = true 6 | page_html 7 | 8 | updateComponentDropdown () { 9 | $('.navbar-tab-item').css('display', this.navbar_item_style) 10 | $('.bs4-item').css('display', this.bs4_item_style) 11 | 12 | const component = this.navbar_item_style === 'none' ? 'header' : 'tab' 13 | if (!$(`#sidebar-${component}-body`).hasClass('show')) { 14 | $(`#sidebar-${component}-header button`).trigger('click') 15 | } 16 | }; 17 | 18 | getPageHTML (html, title = '') { 19 | return html.replaceAll('$page_id$', this.getTabID()).replaceAll('$title$', title) 20 | }; 21 | 22 | updatePage () { 23 | const title = $('#canvas-title').html() 24 | $('.page-canvas').html(this.getPageHTML(this.page_html, title)) 25 | }; 26 | 27 | enableSortablePage (selector, by = 'id') { 28 | if (by === 'id') { 29 | Sortable.create(document.getElementById(selector), { 30 | group: { 31 | name: 'shared', 32 | put: function (_to, _from, clone) { 33 | return !clone.classList.contains('col-sm') 34 | } 35 | } 36 | }) 37 | } else { 38 | document.getElementsByClassName(selector).forEach(el => { 39 | Sortable.create(el, { 40 | group: { 41 | name: 'shared', 42 | put: function (_to, _from, clone) { 43 | return !clone.classList.contains('col-sm') 44 | } 45 | } 46 | }) 47 | }) 48 | } 49 | }; 50 | 51 | getTabID () { 52 | return Math.round(Math.random() * 8999 + 1000) 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /R/cache.R: -------------------------------------------------------------------------------- 1 | #' Find designer directory 2 | #' 3 | #' @description 4 | #' Searches the following locations in order for somewhere to read/write: 5 | #' 6 | #' - The system environment variable R_DESIGNER_CACHE 7 | #' - The shared data directory 8 | #' - The user data directory 9 | #' 10 | #' The aim is to make the bookmarked designs as public as possible, and giving 11 | #' people using a server more options to store the bookmarks, especially if 12 | #' the site data directory isn't available 13 | #' 14 | #' @inheritParams base::file.access 15 | #' 16 | #' @noRd 17 | find_cache_dir <- function(mode = 2L) { 18 | custom_dir <- Sys.getenv("R_DESIGNER_CACHE", "") 19 | if (dir.exists(custom_dir) && file.access(custom_dir, mode = mode) == 0L) { 20 | return(custom_dir) 21 | } else if (dir.exists(custom_dir)) { 22 | stop("Custom cache directory is unwritable. Please change directory permissions") 23 | } 24 | 25 | shared_dir <- rappdirs::site_data_dir( 26 | appname = "designer", 27 | appauthor = "r-designer", 28 | version = paste0("v", DESIGNER_VERSION) 29 | ) 30 | parent_shared_dir <- rappdirs::site_data_dir() 31 | 32 | if (file.access(shared_dir, mode = mode) == 0L) { 33 | return(shared_dir) 34 | } else if (!dir.exists(shared_dir) && file.access(parent_shared_dir, mode = mode) == 0L) { 35 | dir.create(shared_dir, recursive = TRUE, showWarnings = FALSE) 36 | return(shared_dir) 37 | } 38 | 39 | personal_dir <- rappdirs::user_data_dir( 40 | appname = "designer", 41 | appauthor = "r-designer", 42 | version = paste0("v", DESIGNER_VERSION) 43 | ) 44 | if (!dir.exists(personal_dir)) { 45 | dir.create(personal_dir, recursive = TRUE, showWarnings = FALSE) 46 | } 47 | personal_dir 48 | } 49 | 50 | DESIGNER_VERSION <- 1L 51 | -------------------------------------------------------------------------------- /R/mod_settings_utils.R: -------------------------------------------------------------------------------- 1 | #' Settings Button 2 | #' 3 | #' @param id HTML id selector of the button 4 | #' @param label Label to show on the button 5 | #' @param contents \code{\link[shiny]{tagList}} to show when clicking the button 6 | #' 7 | #' @noRd 8 | settingsDropdownButton <- function(id, label, contents) { 9 | div( 10 | class = "col px-2", 11 | div( 12 | tags$button( 13 | id = id, 14 | type = "button", 15 | class = "btn btn-block btn-secondary dropdown-toggle", 16 | `data-toggle` = "dropdown", 17 | `aria-expanded` = "false", 18 | label 19 | ), 20 | contents 21 | ) 22 | ) 23 | } 24 | 25 | PAGE_TYPES <- c( 26 | "Standard Page" = "bootstrapPage", 27 | "Fill Page" = "fillPage", 28 | "Fixed Page" = "fixedPage", 29 | "Fluid Page" = "fluidPage", 30 | "Navigation Bar Page" = "navbarPage", 31 | "Dashboard Page" = "dashboardPage" 32 | ) 33 | 34 | PAGE_DESCRIPTIONS <- c( 35 | "Standard Page" = "Basic Bootstrap Page", 36 | "Fill Page" = "Bootstrap Page that always fills the height and width of the browser window", 37 | "Fixed Page" = "Bootstrap Page that maintains a fixed width for content on the page", 38 | "Fluid Page" = "Bootstrap Page that updates the scales the width of the content dependent on page size", 39 | "Navigation Bar Page" = "Bootstrap Page that contains a top level navigation bar to toggle a set of tabs", 40 | "Dashboard Page" = "AdminLTE3 Dashboard Page" 41 | ) 42 | 43 | pageOptions <- function(ns) { 44 | div( 45 | `aria-labelledby` = ns("page_type_button"), 46 | class = "dropdown-menu dropdown-menu-right dropdown-menu-wide page-type-dropdown clickable-dropdown", 47 | tags$form( 48 | class = "px-2", 49 | radioButtons(ns("page_type"), NULL, PAGE_TYPES, selected = character(0L)) 50 | ) 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rocker/verse:4.2.2 2 | RUN apt-get update && apt-get install -y git-core libcurl4-openssl-dev libicu-dev libssl-dev libxml2-dev make pandoc zlib1g-dev && rm -rf /var/lib/apt/lists/* 3 | RUN mkdir -p /usr/local/lib/R/etc/ /usr/lib/R/etc/ 4 | RUN echo "options(repos = c(CRAN = 'https://cran.rstudio.com/'), download.file.method = 'libcurl', Ncpus = 4)" | tee /usr/local/lib/R/etc/Rprofile.site | tee /usr/lib/R/etc/Rprofile.site 5 | RUN R -e 'install.packages("remotes")' 6 | RUN Rscript -e 'remotes::install_version("rappdirs", upgrade = "never", version = "0.3.3")' 7 | RUN Rscript -e 'remotes::install_version("jsonlite", upgrade = "never", version = "1.8.4")' 8 | RUN Rscript -e 'remotes::install_version("htmltools", upgrade = "never", version = "0.5.5")' 9 | RUN Rscript -e 'remotes::install_version("fontawesome", upgrade = "never", version = "0.5.1")' 10 | RUN Rscript -e 'remotes::install_version("bslib", upgrade = "never", version = "0.4.2")' 11 | RUN Rscript -e 'remotes::install_version("shiny", upgrade = "never", version = "1.7.4")' 12 | RUN Rscript -e 'remotes::install_version("config", upgrade = "never", version = "0.3.1")' 13 | RUN Rscript -e 'remotes::install_version("shinyscreenshot", upgrade = "never", version = "0.2.0")' 14 | RUN Rscript -e 'remotes::install_version("shinipsum", upgrade = "never", version = "0.1.0")' 15 | RUN Rscript -e 'remotes::install_version("golem", upgrade = "never", version = "0.4.0")' 16 | RUN Rscript -e 'remotes::install_version("cicerone", upgrade = "never", version = "1.0.4")' 17 | RUN Rscript -e 'remotes::install_version("bs4Dash", upgrade = "never", version = "2.2.1")' 18 | RUN mkdir /build_zone 19 | ADD . /build_zone 20 | WORKDIR /build_zone 21 | RUN R -e 'remotes::install_local(upgrade = "never")' 22 | RUN rm -rf /build_zone 23 | EXPOSE 80 24 | CMD R -e "options('shiny.port' = 80, shiny.host = '0.0.0.0'); designer::designApp()" 25 | -------------------------------------------------------------------------------- /vignettes/templates.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Using Templates with {designer}" 3 | output: rmarkdown::html_vignette 4 | vignette: > 5 | %\VignetteIndexEntry{templates} 6 | %\VignetteEngine{knitr::rmarkdown} 7 | %\VignetteEncoding{UTF-8} 8 | --- 9 | 10 | ```{r, include = FALSE} 11 | knitr::opts_chunk$set( 12 | collapse = TRUE, 13 | comment = "#>" 14 | ) 15 | ``` 16 | 17 | One of the key features of `{designer}` introduced in version 0.3.0 is the ability to reuse previously saved templated created in the application. 18 | 19 | ## Stored Files 20 | 21 | By default, the stored layouts will be stored in a shared location so that prototypes can be shared amongst anyone using the same server instance. 22 | 23 | The order of selecting the cached location is: 24 | 25 | 1. The file path assigned to the global variable `R_DESIGNER_CACHE` 26 | 2. The site data directory, obtained from `rappdirs::site_data_dir` 27 | 3. The user cache directory, obtained from `rappdirs::user_data_dir` 28 | 29 | ## Storing Templates 30 | 31 | Once you have created a template, click on the "Templates" dropdown and select the save option. There a modal will be given asking to give a title and description of the template, as well as the option to supply your name for future reference. From there you can either save the template or share by send the extended URL to others with access to the application. When the load the designer application, the template will be pre-populated in the app. 32 | 33 | ![Screenshot of the save template modal](../man/figures/save_template.png) 34 | 35 | ## Overwriting Exisitng Templates 36 | 37 | Whether it is because of discussions with team-mates, or requirements changing, templates will change over time. In the application, there is the ability to overwrite available templates. Clicking on the "Save" button will allow the option to update one of the templates, giving the template title and author. 38 | -------------------------------------------------------------------------------- /srcjs/component/__tests__/Component.test.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../Component' 2 | 3 | test('Component sanity test - constructs successfully', () => { 4 | const component = new Component() 5 | expect(component).toBeDefined() 6 | }) 7 | 8 | test('Component - basic HTML constructor works', () => { 9 | const component = new Component() 10 | expect(component.createComponent()).toEqual('
    ') 11 | }) 12 | 13 | test('Component - replacing placeholders correctly replaces strings', () => { 14 | const component = new Component() 15 | 16 | const html_1 = '<$tag_name$>' 17 | expect(component.replaceHTMLPlaceholders(html_1, { tag_name: 'h1' })).toEqual('

    ') 18 | 19 | const html_2 = '<$tag_name$>tag_name' 20 | expect(component.replaceHTMLPlaceholders(html_2, { tag_name: 'h1' })).toEqual('

    tag_name

    ') 21 | }) 22 | 23 | test('Component - replacing placeholders works with multiple placeholders', () => { 24 | const component = new Component() 25 | 26 | const html_1 = '<$tag_name$>$title$' 27 | expect(component.replaceHTMLPlaceholders(html_1, { tag_name: 'h1', title: 'test' })).toEqual('

    test

    ') 28 | 29 | const html_2 = '<$tag_name$>$title$ - title' 30 | expect(component.replaceHTMLPlaceholders(html_2, { tag_name: 'h1', title: 'test' })).toEqual('

    test - title

    ') 31 | }) 32 | 33 | test('Component - Can create random ID', () => { 34 | const component = new Component() 35 | 36 | expect(component.createID()).toMatch(/^\w+$/) 37 | expect(component.createID('test')).toMatch(/^test_\w+$/) 38 | }) 39 | 40 | test('Component - CSS Units correctly validated', () => { 41 | const component = new Component() 42 | 43 | expect(component.validateCssUnit(10, 'BAD')).toEqual('10px') 44 | expect(component.validateCssUnit('10rem', 'BAD')).toEqual('10rem') 45 | expect(component.validateCssUnit('10sadem', 'BAD')).toEqual('BAD') 46 | }) 47 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `{designer}` 2 | 3 | This outlines how to propose a change to designer. 4 | 5 | ## Fixing typos 6 | 7 | You can fix typos, spelling mistakes, or grammatical errors in the documentation directly using the GitHub web interface, as long as the changes are made in the _source_ file. 8 | This generally means you'll need to edit [roxygen2 comments](https://roxygen2.r-lib.org/articles/roxygen2.html) in an `.R`, not a `.Rd` file. 9 | You can find the `.R` file that generates the `.Rd` by reading the comment in the first line. 10 | 11 | ## Bigger changes 12 | 13 | If you want to make a bigger change, it's a good idea to first file an issue and make sure someone agrees that it’s needed. 14 | If you’ve found a bug, please file an issue that illustrates the bug with a minimal 15 | [reprex](https://www.tidyverse.org/help/#reprex) (this will also help you write a unit test, if needed). 16 | 17 | ### Pull request process 18 | 19 | * Fork the package and clone onto your computer. If you haven't done this before, we recommend using `usethis::create_from_github("ashbaldry/designer", fork = TRUE)`. 20 | 21 | * Install all development dependences with `devtools::install_dev_deps()`, and then make sure the package passes R CMD check by running `devtools::check()`. 22 | If R CMD check doesn't pass cleanly, it's a good idea to ask for help before continuing. 23 | * Create a Git branch for your pull request (PR). We recommend using `usethis::pr_init("brief-description-of-change")`. 24 | 25 | * Make your changes, commit to git, and then create a PR by running `usethis::pr_push()`, and following the prompts in your browser. 26 | The title of your PR should briefly describe the change. 27 | The body of your PR should contain `Fixes #issue-number`. 28 | 29 | * For user-facing changes, add a bullet to the top of `NEWS.md` (i.e. just below the first header). Follow the style described in . 30 | -------------------------------------------------------------------------------- /srcjs/component/Input.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Input extends Component { 4 | constructor (type) { 5 | super() 6 | this.type = type 7 | this.updateComponent(true) 8 | } 9 | 10 | types = [ 11 | { value: 'text_input', label: 'Text', r_func: 'textInput' }, 12 | { value: 'textarea', label: 'Textarea', r_func: 'textAreaInput' }, 13 | { value: 'numeric', label: 'Numeric', r_func: 'numericInput' }, 14 | { value: 'password', label: 'Password', r_func: 'passwordInput' } 15 | ] 16 | 17 | html = ` 18 |
    $input_tag$
    22 | ` 23 | 24 | createComponent () { 25 | const label = $(`#sidebar-${this.type}-label`).val() 26 | 27 | let id = $(`#sidebar-${this.type}-id`).val() 28 | id = id === '' ? this.createID('input') : id 29 | 30 | const input_info = this.types.find(x => x.value === this.type) 31 | if (!input_info) return 32 | const r_func = input_info.r_func 33 | 34 | let input_tag 35 | if (this.type === 'textarea') { 36 | input_tag = '' 37 | } else { 38 | input_tag = `` 39 | } 40 | 41 | const width = this.validateCssUnit($(`#sidebar-${this.type}-width`).val()) 42 | const style_str = width ? `style="width: ${width};"` : '' 43 | const width_str = width ? `, width = "${width}"` : '' 44 | 45 | return this.replaceHTMLPlaceholders(this.html, { 46 | id, 47 | label, 48 | r_func, 49 | input_tag, 50 | style_str, 51 | width_str 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /srcjs/input/canvas-canvas-input.js: -------------------------------------------------------------------------------- 1 | import { page, createPage } from '../page/utils' 2 | import { Column } from '../component/Column' 3 | import { Row } from '../component/Row' 4 | import { InputPanel } from '../component/InputPanel' 5 | 6 | export const canvasBinding = new Shiny.InputBinding() 7 | 8 | $.extend(canvasBinding, { 9 | find: function (scope) { 10 | return $(scope).find('.page-canvas-shell') 11 | }, 12 | getValue: function (el) { 13 | return $(el).find('.page-canvas').html() 14 | }, 15 | setValue: function (el, value) { 16 | $(el).find('.page-canvas').html(value) 17 | }, 18 | subscribe: function (el, callback) { 19 | const observer = new MutationObserver(function () { callback() }) 20 | observer.observe(el, { subtree: true, childList: true, attributes: true }) 21 | }, 22 | unsubscribe: function (el) { 23 | $(el).off('.page-canvas-shell') 24 | }, 25 | receiveMessage (el, data) { 26 | $('.canvas-modal').css('display', 'none') 27 | 28 | createPage() 29 | page.updatePage() 30 | 31 | this.setValue(el, data) 32 | 33 | const sortableSettings = new Column(update_component = false).sortable_settings 34 | const sortableRowSettings = new Row(update_component = false).sortable_settings 35 | const sortableInputPanelSettings = new InputPanel(update_component = false).sortableSettings 36 | 37 | PARENT_DESIGNER_CLASSES.map(x => enableSortableComponent(x, sortableSettings)) 38 | enableSortableComponent('designer-element row', sortableRowSettings) 39 | enableSortableComponent('designer-element shiny-input-panel', sortableInputPanelSettings) 40 | 41 | if (page.enable_on_load) { 42 | page.enableSortablePage('canvas-page') 43 | } 44 | page.updateComponentDropdown() 45 | } 46 | }) 47 | 48 | const PARENT_DESIGNER_CLASSES = ['tab-pane', 'designer-element col-sm', 'designer-element card-body'] 49 | 50 | function enableSortableComponent (selector, settings) { 51 | document.getElementsByClassName(selector).forEach(el => { 52 | Sortable.create(el, settings) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /R/mod_canvas_ui.R: -------------------------------------------------------------------------------- 1 | #' Canvas UI Function 2 | #' 3 | #' @description A shiny Module. 4 | #' 5 | #' @param id,input,output,session Internal parameters for {shiny}. 6 | #' 7 | #' @noRd 8 | CanvasModUI <- function(id) { 9 | ns <- NS(id) 10 | 11 | tags$section( 12 | class = "page-canvas-shell", 13 | id = ns("html"), 14 | span( 15 | class = "page-preview-button", 16 | screenshtButton(btn_id = ns("screenshot"), class = "btn-outline-dark"), 17 | tags$button( 18 | id = ns("close_preview"), 19 | class = "btn btn-outline-dark", 20 | shiny::icon("xmark", "aria-hidden" = "true"), 21 | "Close Preview" 22 | ) 23 | ), 24 | tags$section( 25 | class = "page-canvas-header", 26 | tags$svg( 27 | xmlns = "http://www.w3.org/2000/svg", 28 | width = "64", 29 | height = "14", 30 | viewBox = "0 0 64 14", 31 | tags$g( 32 | fill = "none", 33 | `fill-rule` = "evenodd", 34 | transform = "translate(1 1)", 35 | safari_circle("16", "#FF5F56", "#E0443E"), 36 | safari_circle("36", "#FFBD2E", "#DEA123"), 37 | safari_circle("56", "#27C93F", "#1AAB29") 38 | ) 39 | ), 40 | div( 41 | id = ns("title"), 42 | class = "page-canvas-title", 43 | "Shiny Application" 44 | ), 45 | tags$style(id = ns("style"), type = "text/css") 46 | ), 47 | tags$section( 48 | class = "page-canvas", 49 | id = ns("canvas") 50 | ), 51 | 52 | div( 53 | id = ns("menu"), 54 | class = "right-click-menu", 55 | div( 56 | class = "item", 57 | id = ns("delete"), 58 | shiny::icon("xmark"), 59 | "Delete" 60 | ) 61 | ), 62 | 63 | div( 64 | class = "canvas-modal", 65 | id = ns("modal"), 66 | h3( 67 | class = "canvas-modal-title", 68 | "Select Page Type" 69 | ), 70 | div( 71 | class = "canvas-page-choices", 72 | lapply(seq_along(PAGE_TYPES), createPageItem) 73 | ) 74 | ) 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /srcjs/component/ValueBox.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class ValueBox extends Component { 4 | html = ` 5 |
    8 |
    9 |
    10 | $value$ 11 |

    12 | $label$ 13 |

    14 |
    15 | $icon_html$ 16 | 17 |
    18 |
    19 | ` 20 | 21 | constructor () { 22 | super() 23 | this.updateComponent(true) 24 | } 25 | 26 | createComponent () { 27 | const value = $('#sidebar-value_box-value').val() 28 | const label = $('#sidebar-value_box-label').val() 29 | 30 | const width = $('#sidebar-value_box-width_num').val() 31 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 32 | const width_r = width > 0 ? width : 'NULL' 33 | 34 | const tab_icon = $('#sidebar-value_box-icon').val() 35 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 36 | const icon_class = tab_icon === '' ? '' : $('#sidebar-value_box-icon option').html().includes('fab') ? 'fab' : 'fa' 37 | const icon_html = tab_icon === '' ? '' : `
    ` 38 | 39 | const background = $('#sidebar-value_box-background').val() 40 | const background_class = `bg-${background}` 41 | 42 | return this.replaceHTMLPlaceholders(this.html, { 43 | value, 44 | label, 45 | width_class, 46 | width_r, 47 | icon_html, 48 | icon_r, 49 | colour: background, 50 | colour_class: background_class 51 | }) 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /srcjs/page/DashboardPage.js: -------------------------------------------------------------------------------- 1 | import { Page } from './Page' 2 | 3 | export class DashboardPage extends Page { 4 | name = 'dashboardPage' 5 | navbar_item_style = '' 6 | bs4_item_style = '' 7 | enable_on_load = false 8 | page_html = ` 9 |
    11 | 20 | 33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 | ` 40 | }; 41 | -------------------------------------------------------------------------------- /man/designApp.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/run_app.R 3 | \name{designApp} 4 | \alias{designApp} 5 | \title{Run the Shiny Application} 6 | \usage{ 7 | designApp( 8 | onStart = NULL, 9 | options = list(), 10 | enableBookmarking = "url", 11 | uiPattern = "/", 12 | ... 13 | ) 14 | } 15 | \arguments{ 16 | \item{onStart}{A function that will be called before the app is actually run. 17 | This is only needed for \code{shinyAppObj}, since in the \code{shinyAppDir} 18 | case, a \code{global.R} file can be used for this purpose.} 19 | 20 | \item{options}{Named options that should be passed to the \code{runApp} call 21 | (these can be any of the following: "port", "launch.browser", "host", "quiet", 22 | "display.mode" and "test.mode"). You can also specify \code{width} and 23 | \code{height} parameters which provide a hint to the embedding environment 24 | about the ideal height/width for the app.} 25 | 26 | \item{enableBookmarking}{Can be one of \code{"url"}, \code{"server"}, or 27 | \code{"disable"}. The default value, \code{NULL}, will respect the setting from 28 | any previous calls to \code{\link[shiny:enableBookmarking]{enableBookmarking()}}. See \code{\link[shiny:enableBookmarking]{enableBookmarking()}} 29 | for more information on bookmarking your app.} 30 | 31 | \item{uiPattern}{A regular expression that will be applied to each \code{GET} 32 | request to determine whether the \code{ui} should be used to handle the 33 | request. Note that the entire request path must match the regular 34 | expression in order for the match to be considered successful.} 35 | 36 | \item{...}{arguments to pass to \code{golem_opts}. See \code{\link[golem]{get_golem_options}} for more details.} 37 | } 38 | \value{ 39 | This function does not return a value; interrupt R to stop the application (usually by pressing Ctrl+C or Esc). 40 | } 41 | \description{ 42 | Runs the designer Shiny application. 43 | 44 | For more information about how the application works, either run the "Help" guide in-app, or run 45 | \code{vignette("designer")}. 46 | } 47 | \examples{ 48 | \dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} 49 | designApp() 50 | \dontshow{\}) # examplesIf} 51 | } 52 | -------------------------------------------------------------------------------- /R/cicerone_guide.R: -------------------------------------------------------------------------------- 1 | #' Application Guide 2 | #' @noRd 3 | guide <- cicerone::Cicerone$new( 4 | )$step( 5 | el = "settings-page_type_button", 6 | title = "Page Type", 7 | description = paste( 8 | "First thing to do is select the type of page:" 14 | ) 15 | )$step( 16 | el = "sidebar-accordion", 17 | title = "Components", 18 | description = paste( 19 | "Components are the bread and butter of building a UI.", 20 | "The selection of components include rows and columns for designing the layout,", 21 | "and the all important inputs and outputs." 22 | ) 23 | )$step( 24 | el = "sidebar-header-body", 25 | title = "Component Settings", 26 | description = paste( 27 | "After a component is selected, some customisation is available ", 28 | "such as the text in the component and how wide it should be." 29 | ) 30 | )$step( 31 | el = "component_delete", 32 | title = "Delete Component", 33 | description = paste( 34 | "If you do include something in the UI that you no longer need, drag it here for it to be removed.", 35 | "Alternatively, right-click on an element and delete from the menu instead." 36 | ) 37 | )$step( 38 | el = "settings-code_button", 39 | title = "R Code", 40 | description = paste( 41 | "Once you are happy with the layout, you can copy or download the R code required to replicate the UI below." 42 | ) 43 | )$step( 44 | el = "settings-template_button", 45 | title = "R Code", 46 | description = paste( 47 | "Alternatively if you want to load a previously saved template, select one of the templates here." 48 | ) 49 | )$step( 50 | el = "settings-options_button", 51 | title = "Additional Options", 52 | description = paste( 53 | "Here you can toggle development tools, such as labels and the dotted borders,", 54 | "as well as changing the title and a full page preview of the application." 55 | ) 56 | ) 57 | -------------------------------------------------------------------------------- /man/component_setting.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/mod_sidebar_components.R 3 | \name{compSettingTag} 4 | \alias{compSettingTag} 5 | \alias{compSettingType} 6 | \alias{compSettingPlot} 7 | \alias{compSettingValue} 8 | \alias{compSettingLabel} 9 | \alias{compSettingID} 10 | \alias{compSettingIcon} 11 | \alias{compSettingColour} 12 | \alias{compSettingBackground} 13 | \alias{compSettingFill} 14 | \alias{compSettingText} 15 | \alias{compSettingTextArea} 16 | \alias{compSettingChoices} 17 | \alias{compSettingRange} 18 | \alias{compSettingInline} 19 | \alias{compSettingDownload} 20 | \alias{compSettingWidth} 21 | \alias{compSettingHeight} 22 | \alias{compSettingWidthNum} 23 | \alias{compSettingOffset} 24 | \title{Bootstrap Component Inputs} 25 | \usage{ 26 | compSettingTag(id, choices = NULL) 27 | 28 | compSettingType(id, choices) 29 | 30 | compSettingPlot(id) 31 | 32 | compSettingValue(id) 33 | 34 | compSettingLabel(id, label = "Label", optional = FALSE) 35 | 36 | compSettingID(id) 37 | 38 | compSettingIcon(id) 39 | 40 | compSettingColour(id, status = FALSE) 41 | 42 | compSettingBackground(id) 43 | 44 | compSettingFill(id, label = "Fill Whole Box") 45 | 46 | compSettingText(id, value = NULL) 47 | 48 | compSettingTextArea(id) 49 | 50 | compSettingChoices(id) 51 | 52 | compSettingRange(id) 53 | 54 | compSettingInline(id) 55 | 56 | compSettingDownload(id) 57 | 58 | compSettingWidth(id) 59 | 60 | compSettingHeight(id) 61 | 62 | compSettingWidthNum(id, value = 3L, min = 1L) 63 | 64 | compSettingOffset(id) 65 | } 66 | \arguments{ 67 | \item{id}{Namespace to include the component} 68 | 69 | \item{choices}{A vector of potential choices to include in the component} 70 | 71 | \item{label}{Label of the input} 72 | 73 | \item{optional}{Logical, is the input optional?} 74 | 75 | \item{status}{Logical, are only status colours allowed, default is \code{FALSE}} 76 | 77 | \item{value}{Value given to the component input} 78 | 79 | \item{min}{Minimum value given to the component input} 80 | } 81 | \value{ 82 | A \code{shiny.tag.list} of settings specific to the selected component 83 | } 84 | \description{ 85 | A way to be able to adjust components so that can more easily visualise how the shiny application will look. 86 | } 87 | \seealso{ 88 | \code{\link{component}} 89 | } 90 | -------------------------------------------------------------------------------- /R/ui_utils.R: -------------------------------------------------------------------------------- 1 | #' Warning Modal 2 | #' 3 | #' @description 4 | #' Creates a modal to warn the user about the consequences of a particular action they're 5 | #' about to make. 6 | #' 7 | #' @param id ID to give to the modal 8 | #' @param text Character string of the body of the warning message 9 | #' @param confirm_id,cancel_id HTML ID references for the confirm and cancel buttons 10 | #' @param confirm_text,cancel_text Labels to give the confirm and cancel buttons 11 | #' 12 | #' @return HTML for a modal 13 | #' 14 | #' @noRd 15 | warningModal <- function(id, text, confirm_id, confirm_text, cancel_id, cancel_text) { 16 | div( 17 | class = "modal fade", 18 | id = id, 19 | tabindex = "-1", 20 | `aria-hidden` = "true", 21 | `data-bs-keyboard` = "false", 22 | `data-keyboard` = "false", 23 | div( 24 | class = "modal-dialog", 25 | role = "document", 26 | div( 27 | class = "modal-content", 28 | div( 29 | class = "modal-header", 30 | h5(class = "modal-title", "Warning!") 31 | ), 32 | div( 33 | class = "modal-body", 34 | p(text) 35 | ), 36 | div( 37 | class = "modal-footer", 38 | tags$button( 39 | id = cancel_id, 40 | type = "button", 41 | class = "btn btn-secondary", 42 | `data-dismiss` = "modal", 43 | `data-bs-dismiss` = "modal", 44 | shiny::icon("xmark"), 45 | cancel_text 46 | ), 47 | tags$button( 48 | id = confirm_id, 49 | type = "button", 50 | class = "btn btn-primary", 51 | `data-dismiss` = "modal", 52 | `data-bs-dismiss` = "modal", 53 | shiny::icon("check"), 54 | confirm_text 55 | ) 56 | ) 57 | ) 58 | ) 59 | ) 60 | } 61 | 62 | screenshtButton <- function(btn_id, ...) { 63 | btn <- shinyscreenshot::screenshotButton( 64 | selector = ".designer-page-template", 65 | label = "Snapshot UI", 66 | filename = "ui_wireframe", 67 | ... 68 | ) 69 | btn[[2L]]$attribs$class <- sub(" btn-default", "", btn[[2L]]$attribs$class) 70 | btn[[2L]]$attribs$onclick <- sub(btn[[2L]]$attribs$id, btn_id, btn[[2L]]$attribs$onclick, fixed = TRUE) 71 | btn[[2L]]$attribs$id <- btn_id 72 | btn 73 | } 74 | -------------------------------------------------------------------------------- /inst/srcjs/bs-custom-file-input/bs-custom-file-input.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * bsCustomFileInput v1.3.4 (https://github.com/Johann-S/bs-custom-file-input) 3 | * Copyright 2018 - 2020 Johann-S 4 | * Licensed under MIT (https://github.com/Johann-S/bs-custom-file-input/blob/master/LICENSE) 5 | */ 6 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).bsCustomFileInput=t()}(this,function(){"use strict";var s={CUSTOMFILE:'.custom-file input[type="file"]',CUSTOMFILELABEL:".custom-file-label",FORM:"form",INPUT:"input"},l=function(e){if(0 8 |
    9 | 10 | $icon_html$ 11 | 12 |
    13 | $label$ 14 | $value$ 15 |
    16 | 17 |
    18 |
    19 | ` 20 | 21 | constructor () { 22 | super() 23 | this.updateComponent(true) 24 | } 25 | 26 | createComponent () { 27 | const value = $('#sidebar-info_box-value').val() 28 | const label = $('#sidebar-info_box-label').val() 29 | 30 | const width = $('#sidebar-info_box-width_num').val() 31 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 32 | const width_r = width > 0 ? width : 'NULL' 33 | 34 | const tab_icon = $('#sidebar-info_box-icon').val() 35 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 36 | const icon_class = tab_icon === '' ? '' : $('#sidebar-info_box-icon option').html().includes('fab') ? 'fab' : 'fa' 37 | const icon_html = tab_icon === '' ? '' : `` 38 | 39 | const background = $('#sidebar-info_box-background').val() 40 | const background_class = `bg-${background}` 41 | const fill_box = document.getElementById('sidebar-info_box-fill').checked 42 | const fill_r = fill_box ? ', fill = TRUE' : '' 43 | 44 | return this.replaceHTMLPlaceholders(this.html, { 45 | value, 46 | label, 47 | width_class, 48 | width_r, 49 | icon_html, 50 | icon_r, 51 | colour: background, 52 | colour_class: fill_box ? background_class : '', 53 | colour_class2: fill_box ? '' : background_class, 54 | fill_r 55 | }) 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /R/mod_template_ui.R: -------------------------------------------------------------------------------- 1 | #' Stored Templates Module 2 | #' 3 | #' @description 4 | #' Module providing access to previously saved templates. 5 | #' 6 | #' @param id The character vector to use for the namespace. 7 | #' 8 | #' @return 9 | #' UI and server code to display selected HTML elements 10 | #' 11 | #' @noRd 12 | TemplateModUI <- function(id) { 13 | ns <- NS(id) 14 | 15 | tagList( 16 | tags$form( 17 | class = "code-ui-form", 18 | tags$fieldset( 19 | class = "form-group", 20 | actionButton( 21 | ns("save_button"), 22 | "Save", 23 | shiny::icon("floppy-disk") 24 | ) 25 | ), 26 | templateSearchInput( 27 | ns("search") 28 | ) 29 | ), 30 | templateSelectionInput( 31 | ns("select"), 32 | get_template_index() 33 | ) 34 | ) 35 | } 36 | 37 | templateSearchInput <- function(id) { 38 | tags$fieldset( 39 | class = "form-group input-group", 40 | tags$input( 41 | id = id, 42 | class = "form-control", 43 | type = "text", 44 | `aria-label` = "search templates", 45 | placeholder = "Search templates..." 46 | ), 47 | tags$div( 48 | vlass = "input-group-append", 49 | tags$span( 50 | class = "input-group-text", 51 | " ", 52 | shiny::icon("magnifying-glass") 53 | ) 54 | ) 55 | ) 56 | } 57 | 58 | templateSelectionInput <- function(id, template_index) { 59 | if (nrow(template_index) > 0L) { 60 | template_tags <- apply(template_index, 1L, createTemplateSelection) 61 | } else { 62 | template_tags <- NULL 63 | } 64 | 65 | tags$section( 66 | class = "template-select", 67 | id = id, 68 | template_tags 69 | ) 70 | } 71 | 72 | createTemplateSelection <- function(template_info) { 73 | tags$article( 74 | class = "template-option", 75 | `data-value` = template_info[["id"]], 76 | `data-page` = template_info[["page"]], 77 | div( 78 | class = "info", 79 | div( 80 | class = "title", 81 | template_info[["title"]] 82 | ), 83 | div( 84 | class = "description", 85 | template_info[["description"]] 86 | ) 87 | ), 88 | span( 89 | class = "author", 90 | template_info[["user"]] 91 | ), 92 | span( 93 | class = "delete", 94 | shiny::icon("x", title = "Delete Template") 95 | ) 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /R/mod_code_srv.R: -------------------------------------------------------------------------------- 1 | #' @param ui_code Reactive object containing JSON string of the UI in the "App UI" tab 2 | #' 3 | #' @noRd 4 | CodeModuleServer <- function(id, ui_code) { 5 | moduleServer(id, function(input, output, session) { 6 | setBookmarkExclude(c("save", "download", "file_type", "file_name", "options")) 7 | 8 | observeEvent(input$file_type, { 9 | updateTextInput( 10 | session = session, 11 | inputId = "file_name", 12 | label = switch(input$file_type, "ui" = "File Name", "module" = "Module Name"), 13 | value = switch(input$file_type, "ui" = "ui.R", "module" = "Template"), 14 | ) 15 | }) 16 | 17 | observeEvent(input$save, ignoreInit = TRUE, { 18 | writeToUI(ui_code(), input$file_type, input$file_name, input$app_type) 19 | }) 20 | 21 | output$download <- downloadHandler( 22 | filename = function() { 23 | if (input$file_type == "ui") { 24 | input$file_name 25 | } else { 26 | paste0("mod_", tolower(gsub("\\W", "_", input$file_name)), "_ui.R") 27 | } 28 | }, 29 | content = function(file) { 30 | module_name <- if (input$file_type == "ui") NULL else input$file_name 31 | r_code <- jsonToRScript(ui_code(), module_name = module_name) 32 | writeLines(r_code, file) 33 | } 34 | ) 35 | 36 | r_code <- reactive({ 37 | module_name <- if (input$file_type == "ui") NULL else input$file_name 38 | jsonToRScript(ui_code(), module_name = module_name, app_type = input$app_type) 39 | }) 40 | 41 | output$code <- renderPrint(cat(r_code())) 42 | }) 43 | } 44 | 45 | writeToUI <- function(code, file_type = c("ui", "module"), module_name = NULL, 46 | app_type = c("app", "golem", "rhino")) { 47 | file_type <- match.arg(file_type) 48 | app_type <- match.arg(app_type) 49 | 50 | r_dir <- switch(app_type, "app" = ".", "golem" = "R", "rhino" = "app/view") 51 | if (!file.exists(r_dir)) dir.create(r_dir, recursive = TRUE) 52 | 53 | if (file_type == "ui") { 54 | r_code <- jsonToRScript(code) 55 | file_name <- file.path(r_dir, module_name) 56 | } else { 57 | r_code <- jsonToRScript(code, module_name = module_name) 58 | file_name <- file.path(r_dir, paste0("mod_", tolower(gsub(" ", "_", module_name)), "_ui.R")) 59 | } 60 | 61 | sink(file = file_name, append = FALSE) 62 | cat(r_code) 63 | sink() 64 | } 65 | -------------------------------------------------------------------------------- /srcjs/component/Box.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Box extends Component { 4 | has_card_body = true 5 | html = ` 6 |
    9 |
    10 |
    11 |

    $label$

    12 |
    13 | 16 |
    17 |
    18 |
    19 |
    20 | 24 |
    25 | ` 26 | 27 | sortable_settings = { 28 | group: { 29 | name: 'shared', 30 | put: function (_to, _from, clone) { 31 | return !clone.classList.contains('col-sm') 32 | } 33 | } 34 | } 35 | 36 | constructor () { 37 | super() 38 | this.updateComponent(true) 39 | } 40 | 41 | createComponent () { 42 | const label = $('#sidebar-box-label').val() 43 | 44 | const width = $('#sidebar-box-width_num').val() 45 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 46 | const width_r = width > 0 ? width : 'NULL' 47 | 48 | const colour = $('#sidebar-box-colour').val() 49 | const colour_class = colour === 'white' ? '' : `card-outline card-${colour}` 50 | 51 | const background = $('#sidebar-box-background').val() 52 | const background_class = background === 'white' ? '' : `bg-${background}` 53 | 54 | return this.replaceHTMLPlaceholders(this.html, { 55 | label, 56 | width_class, 57 | width_r, 58 | colour, 59 | colour_class, 60 | background, 61 | background_class 62 | }) 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /srcjs/component/utils.js: -------------------------------------------------------------------------------- 1 | import { Header } from './Header' 2 | import { Row } from './Row' 3 | import { Column } from './Column' 4 | import { Text } from './Text' 5 | import { InputPanel } from './InputPanel' 6 | import { Input } from './Input' 7 | import { FileInput } from './FileInput' 8 | import { SelectInput } from './SelectInput' 9 | import { DateInput } from './DateInput' 10 | import { CheckboxInput } from './Checkbox' 11 | import { CheckboxGroupInput } from './CheckboxGroup' 12 | import { SliderInput } from './SliderInput' 13 | import { Button } from './Button' 14 | import { Output } from './Output' 15 | import { Tab } from './Tab' 16 | import { Tabset } from './Tabset' 17 | import { Box } from './Box' 18 | import { UserBox } from './UserBox' 19 | import { ValueBox } from './ValueBox' 20 | import { InfoBox } from './InfoBox' 21 | import { BlockQuote } from './BlockQuote' 22 | import { Callout } from './Callout' 23 | 24 | export function getComponent (name) { 25 | if (name === 'header') { 26 | return new Header() 27 | } else if (name === 'row') { 28 | return new Row() 29 | } else if (name === 'column') { 30 | return new Column() 31 | } else if (name === 'text') { 32 | return new Text() 33 | } else if (name === 'input_panel') { 34 | return new InputPanel() 35 | } else if (['text_input', 'numeric', 'textarea', 'password'].includes(name)) { 36 | return new Input(name) 37 | } else if (name === 'dropdown') { 38 | return new SelectInput() 39 | } else if (name === 'file') { 40 | return new FileInput() 41 | } else if (name === 'date') { 42 | return new DateInput() 43 | } else if (name === 'checkbox') { 44 | return new CheckboxInput() 45 | } else if (name === 'radio') { 46 | return new CheckboxGroupInput() 47 | } else if (name === 'slider') { 48 | return new SliderInput() 49 | } else if (name === 'button') { 50 | return new Button() 51 | } else if (name === 'output') { 52 | return new Output() 53 | } else if (name === 'tab_panel') { 54 | return new Tab() 55 | } else if (name === 'tabset') { 56 | return new Tabset() 57 | } else if (name === 'box') { 58 | return new Box() 59 | } else if (name === 'user_box') { 60 | return new UserBox() 61 | } else if (name === 'value_box') { 62 | return new ValueBox() 63 | } else if (name === 'info_box') { 64 | return new InfoBox() 65 | } else if (name === 'quote') { 66 | return new BlockQuote() 67 | } else if (name === 'callout') { 68 | return new Callout() 69 | } 70 | 71 | return new Header() 72 | } 73 | -------------------------------------------------------------------------------- /tests/testthat/test-template.R: -------------------------------------------------------------------------------- 1 | test_that("saving a template works", { 2 | # Don't run these tests on the CRAN build servers 3 | skip_on_cran() 4 | 5 | temp_dir <- tempdir() 6 | Sys.setenv(R_DESIGNER_CACHE = temp_dir) 7 | 8 | shiny_app <- designApp() 9 | app <- shinytest2::AppDriver$new(shiny_app, name = "designapp") 10 | 11 | on.exit({ 12 | Sys.setenv(R_DESIGNER_CACHE = "") 13 | app$stop() 14 | }) 15 | 16 | app$click(selector = ".canvas-page-choice[data-page='fixedPage']") 17 | app$wait_for_idle() 18 | app$click(selector = "#settings-template_button") 19 | app$wait_for_idle() 20 | app$click(selector = "#settings-template-save_button") 21 | app$wait_for_idle() 22 | 23 | # Checking that the modal has been generated 24 | expect_identical(app$get_text("#settings-template-title-label"), "Title") 25 | 26 | app$set_inputs( 27 | `settings-template-title` = "Test App", 28 | `settings-template-author` = "Ashley", 29 | `settings-template-description` = "Test description" 30 | ) 31 | 32 | # Checking template is correctly saved 33 | app$click(selector = "#settings-template-save") 34 | app$wait_for_idle(1000L) 35 | expect_true(file.exists(file.path(temp_dir, "index.csv"))) 36 | 37 | template <- read.csv(file.path(temp_dir, "index.csv")) 38 | expect_length(template, 5L) 39 | expect_named(template, c("id", "page", "title", "user", "description")) 40 | 41 | on.exit(add = TRUE, { 42 | file.remove(file.path(temp_dir, "index.csv")) 43 | unlink(file.path(temp_dir, template$id), recursive = TRUE) 44 | }) 45 | 46 | # Checking the template has been added to the UI 47 | template_id <- template$id 48 | app$click(selector = "#settings-template_button") 49 | app$wait_for_idle() 50 | 51 | cm <- app$get_chromote_session() 52 | doc_node_id <- cm$DOM$getDocument()$root$nodeId 53 | 54 | template_parent_id <- cm$DOM$querySelector(doc_node_id, "#settings-template-select") 55 | template_parent_info <- cm$DOM$describeNode(unlist(template_parent_id)) 56 | expect_equal(template_parent_info$node$childNodeCount, 1L) 57 | 58 | template_ui_id <- cm$DOM$querySelector(unlist(template_parent_id), "a") 59 | template_ui_info <- cm$DOM$getAttributes(unlist(template_ui_id)) 60 | 61 | page_id <- which(unlist(template_ui_info$attributes) == "data-page") + 1L 62 | expect_identical(template_ui_info$attributes[[page_id]], template$page) 63 | 64 | id_id <- which(unlist(template_ui_info$attributes) == "data-value") + 1L 65 | expect_identical(template_ui_info$attributes[[id_id]], template_id) 66 | }) 67 | -------------------------------------------------------------------------------- /srcjs/component/CheckboxGroup.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class CheckboxGroupInput extends Component { 4 | types = [ 5 | { value: 'radio', label: 'Radio', r_func: 'radioButtons', role: 'radiogroup' }, 6 | { value: 'checkbox', label: 'Checkbox', r_func: 'checkboxGroupInput', role: 'group' } 7 | ] 8 | 9 | html = ` 10 |
    14 | 15 |
    16 | $choices$ 17 |
    18 |
    19 | ` 20 | 21 | constructor () { 22 | super() 23 | this.updateComponent(true) 24 | } 25 | 26 | createComponent () { 27 | const label = $('#sidebar-radio-label').val() 28 | 29 | let id = $('#sidebar-radio-id').val() 30 | id = id === '' ? this.createID('checkbox') : id 31 | 32 | const type = $('#sidebar-radio-type').val() 33 | const input_info = this.types.find(x => x.value === type) 34 | if (!input_info) return 35 | const r_func = input_info.r_func 36 | const role = input_info.role 37 | 38 | const width = this.validateCssUnit($('#sidebar-radio-width').val()) 39 | const style_str = width ? `style="width: ${width};"` : '' 40 | const width_str = width ? `, width = "${width}"` : '' 41 | 42 | const inline = document.getElementById('sidebar-radio-inline').checked 43 | const inline_class = inline ? '-inline' : '' 44 | const inline_str = inline ? ', inline = TRUE' : '' 45 | const css_class = `shiny-input-${type}group${inline_class}` 46 | 47 | const choices = $('#sidebar-radio-choices').val() 48 | const choices_str = `, choices = c("${choices.replace(/\n/g, '", "')}")` 49 | const choices_html = choices.split('\n').map(x => this.createCheckbox(x, type, inline)).join('') 50 | 51 | return this.replaceHTMLPlaceholders(this.html, { 52 | id, 53 | label, 54 | css_class, 55 | r_func, 56 | role, 57 | choices: choices_html, 58 | choices_str, 59 | inline_str, 60 | style_str, 61 | width_str 62 | }) 63 | }; 64 | 65 | createCheckbox (x, type = 'checkbox', inline = false) { 66 | const check_class = inline ? type + '-inline' : type 67 | return `` 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /srcjs/component/UserBox.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class UserBox extends Component { 4 | has_card_body = true 5 | html = ` 6 |
    9 |
    10 |
    11 |
    12 | 15 |
    16 |

    $label$

    17 |
    18 |
    19 | User Avatar 20 |
    21 |
    22 | 23 |
    24 |
    25 | ` 26 | 27 | sortable_settings = { 28 | group: { 29 | name: 'shared', 30 | put: function (_to, _from, clone) { 31 | return !clone.classList.contains('col-sm') 32 | } 33 | } 34 | } 35 | 36 | constructor () { 37 | super() 38 | this.updateComponent(true) 39 | } 40 | 41 | createComponent () { 42 | const label = $('#sidebar-user_box-label').val() 43 | 44 | const width = $('#sidebar-user_box-width_num').val() 45 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 46 | const width_r = width > 0 ? width : 'NULL' 47 | 48 | const colour = $('#sidebar-user_box-colour').val() 49 | const colour_class = colour === 'white' ? '' : `card-outline card-${colour}` 50 | 51 | const background = $('#sidebar-user_box-background').val() 52 | const background_class = background === 'white' ? '' : `bg-${background}` 53 | 54 | const type = $('#sidebar-user_box-type').val() 55 | 56 | return this.replaceHTMLPlaceholders(this.html, { 57 | label, 58 | width_class, 59 | width_r, 60 | colour, 61 | colour_class, 62 | background, 63 | background_class, 64 | type 65 | }) 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /R/mod_code_ui.R: -------------------------------------------------------------------------------- 1 | #' Template Code Module 2 | #' 3 | #' @description 4 | #' Module showing the user the R code required to create the UI on the "App UI" tab. 5 | #' 6 | #' @param id The character vector to use for the namespace. 7 | #' 8 | #' @return 9 | #' UI and server code to display selected HTML elements 10 | #' 11 | #' @noRd 12 | CodeModUI <- function(id) { 13 | ns <- NS(id) 14 | 15 | tagList( 16 | tags$form( 17 | class = "code-ui-form", 18 | tags$fieldset( 19 | span( 20 | toast("copy_toast", "Copied!"), 21 | tags$button( 22 | class = "copy-ui-button btn btn-default", 23 | role = "button", 24 | icon("copy"), 25 | "Copy" 26 | ) 27 | ), 28 | downloadButton( 29 | ns("download") 30 | ), 31 | if (interactive()) { 32 | actionButton( 33 | ns("save"), 34 | "Save As...", 35 | icon("floppy-disk") 36 | ) 37 | }, 38 | actionButton( 39 | ns("options"), 40 | icon("cogs"), 41 | title = "Saving options" 42 | ) 43 | ), 44 | 45 | tags$fieldset( 46 | id = ns("options_fields"), 47 | class = "save-code-options", 48 | style = "display: none;", 49 | 50 | tagAppendAttributes( 51 | radioButtons( 52 | inputId = ns("file_type"), 53 | label = "File Type", 54 | choices = c("UI" = "ui", "Module" = "module"), 55 | inline = TRUE 56 | ), 57 | class = "form-inline" 58 | ), 59 | tagAppendAttributes( 60 | textInput( 61 | inputId = ns("file_name"), 62 | label = "File Name", 63 | width = "100%", 64 | value = "ui.R" 65 | ), 66 | class = "form-inline" 67 | ), 68 | tagAppendAttributes( 69 | radioButtons( 70 | inputId = ns("app_type"), 71 | label = "App Structure", 72 | choices = c("Stanard" = "app", "{golem}" = "golem", "{rhino}" = "rhino"), 73 | inline = TRUE 74 | ), 75 | class = "form-inline" 76 | ) 77 | ) 78 | ), 79 | 80 | tagAppendAttributes( 81 | verbatimTextOutput(ns("code"), placeholder = TRUE), 82 | class = "code-output" 83 | ) 84 | ) 85 | } 86 | 87 | toast <- function(id, text) { 88 | div( 89 | id = id, 90 | class = "toast hide", 91 | role = "alert", 92 | `aria-live` = "assertive", 93 | `aria-atomic` = "true", 94 | `data-autohide` = "true", 95 | div( 96 | class = "toast-body", 97 | tags$b(text) 98 | ) 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /srcjs/component/Button.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Button extends Component { 4 | types = [ 5 | { value: 'default', css_class: 'btn-default' }, 6 | { value: 'primary', css_class: 'btn-primary' }, 7 | { value: 'secondary', css_class: 'btn-secondary' }, 8 | { value: 'success', css_class: 'btn-success' }, 9 | { value: 'danger', css_class: 'btn-danger' }, 10 | { value: 'warning', css_class: 'btn-warning' }, 11 | { value: 'info', css_class: 'btn-info' }, 12 | { value: 'light', css_class: 'btn-light' }, 13 | { value: 'dark', css_class: 'btn-dark' } 14 | ] 15 | 16 | html = ` 17 | 24 | ` 25 | 26 | constructor () { 27 | super() 28 | this.updateComponent(true) 29 | } 30 | 31 | createComponent () { 32 | const label = $('#sidebar-button-label').val() 33 | 34 | let id = $('#sidebar-button-id').val() 35 | id = id === '' ? this.createID('input') : id 36 | 37 | const input_type = $('#sidebar-button-type').val() 38 | const input_info = this.types.find(x => x.value === input_type) 39 | if (!input_info) return 40 | const btn_class = input_info.css_class 41 | const class_str = input_type === 'default' ? '' : `, class = "${btn_class}"` 42 | 43 | const downloadable = document.getElementById('sidebar-button-download').checked 44 | const r_func = downloadable ? 'downloadButton' : 'actionButton' 45 | let icon_html = downloadable ? '' : '' 46 | const id_arg = downloadable ? 'outputId' : 'inputId' 47 | 48 | const tab_icon = $('#sidebar-button-icon').val() 49 | const icon_r = tab_icon === '' || downloadable ? '' : `, icon = icon("${tab_icon}")` 50 | const icon_class = tab_icon === '' || downloadable ? '' : $('#sidebar-button-icon option').html().includes('fab') ? 'fab' : 'fa' 51 | icon_html = tab_icon === '' || downloadable ? icon_html : `` 52 | 53 | const width = this.validateCssUnit($('#sidebar-button-width').val()) 54 | const style_str = width ? `style="width: ${width};"` : '' 55 | const width_str = width ? `, width = "${width}"` : '' 56 | 57 | return this.replaceHTMLPlaceholders(this.html, { 58 | id, 59 | id_arg, 60 | label, 61 | r_func, 62 | icon_r, 63 | icon_html, 64 | btn_class, 65 | class_str, 66 | style_str, 67 | width_str 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /srcjs/component/Component.js: -------------------------------------------------------------------------------- 1 | export let component 2 | 3 | export class Component { 4 | updatable = true 5 | display_comments = true 6 | display_container = true 7 | has_card_body = false 8 | is_tab = false 9 | html = '
    ' 10 | sortable_settings = null 11 | 12 | constructor () { 13 | component = this 14 | } 15 | 16 | createComponent () { 17 | return this.html 18 | }; 19 | 20 | replaceHTMLPlaceholders (html, options) { 21 | for (const property in options) { 22 | html = html.replaceAll('$' + property + '$', options[property]) 23 | } 24 | return html 25 | }; 26 | 27 | updateComponent (update_sortable = false) { 28 | if (typeof (window) === 'undefined') { 29 | return null 30 | } 31 | 32 | $('.component-container').html(null) 33 | const html = this.createComponent() 34 | $('.component-container').html(html) 35 | this.addComments() 36 | if (update_sortable) { 37 | this.enableSortable() 38 | } 39 | }; 40 | 41 | enableSortable () { 42 | Sortable.create( 43 | document.getElementById('sidebar-container'), { 44 | group: { 45 | name: 'shared', 46 | pull: 'clone', 47 | put: false 48 | }, 49 | onClone: function (evt) { 50 | if (component.sortable_settings) { 51 | if (component.has_card_body) { 52 | Sortable.create($(evt.item).find('.card-body')[0], component.sortable_settings) 53 | } else if (component.is_tab) { 54 | Sortable.create($(evt.item).find('.tab-content'), component.sortable_settings) 55 | } else { 56 | Sortable.create(evt.item, component.sortable_settings) 57 | } 58 | } 59 | }, 60 | onEnd: function (_evt) { 61 | $('.page-canvas [data-toggle="tooltip"]').tooltip() 62 | if (component.updatable || $('#sidebar-comments').val() !== '') { 63 | $('#sidebar-comments').val('') 64 | component.updateComponent() 65 | } 66 | } 67 | }) 68 | }; 69 | 70 | addComments () { 71 | const comments = $('#sidebar-comments').val() 72 | if (comments) { 73 | $('.component-container>.designer-element').attr('data-shinycomments', comments) 74 | $('.component-container>.designer-element').attr('title', comments) 75 | $('.component-container>.designer-element').attr('data-toggle', 'tooltip') 76 | } 77 | }; 78 | 79 | createID (prefix = '') { 80 | prefix = prefix ? prefix + '_' : prefix 81 | return prefix + Math.random().toString(36).substring(2, 12) 82 | }; 83 | 84 | validateCssUnit (x, fallback) { 85 | if (this._regex.test(x)) { 86 | return x 87 | } else if (/^\d+$/.test(x)) { 88 | return x + 'px' 89 | } else { 90 | return fallback 91 | } 92 | }; 93 | 94 | _regex = /^(auto|inherit|fit-content|calc\(.*\)|((\.\d+)|(\d+(\.\d+)?))(%|in|cm|mm|ch|em|ex|rem|pt|pc|px|vh|vw|vmin|vmax))$/ 95 | }; 96 | -------------------------------------------------------------------------------- /srcjs/page/utils.js: -------------------------------------------------------------------------------- 1 | import { BasicPage } from './BasicPage' 2 | import { FillPage } from './FillPage' 3 | import { FixedPage } from './FixedPage' 4 | import { FluidPage } from './FluidPage' 5 | import { BootstrapPage } from './BootstrapPage' 6 | import { NavbarPage } from './NavbarPage' 7 | import { DashboardPage } from './DashboardPage' 8 | import { templateSelected, templateUpated } from '../app/settings' 9 | 10 | export let page 11 | 12 | export function createPage () { 13 | const page_type = $('#settings-page_type input:radio:checked').val() 14 | 15 | if (page_type === 'basicPage') { 16 | page = new BasicPage() 17 | } else if (page_type === 'fillPage') { 18 | page = new FillPage() 19 | } else if (page_type === 'fixedPage') { 20 | page = new FixedPage() 21 | } else if (page_type === 'fluidPage') { 22 | page = new FluidPage() 23 | } else if (page_type === 'bootstrapPage') { 24 | page = new BootstrapPage() 25 | } else if (page_type === 'navbarPage') { 26 | page = new NavbarPage() 27 | } else if (page_type === 'dashboardPage') { 28 | page = new DashboardPage() 29 | } else { 30 | page = new BasicPage() 31 | } 32 | 33 | page.updatePage() 34 | 35 | if (templateSelected()) { 36 | templateUpated() 37 | } else if (page.enable_on_load) { 38 | page.enableSortablePage('canvas-page') 39 | } 40 | 41 | page.updateComponentDropdown() 42 | return page 43 | }; 44 | 45 | export function selectPage () { 46 | let button_el = $(this) 47 | if (!$(this).hasClass('canvas-page-choice')) { 48 | button_el = $(this).closest('.canvas-page-choice') 49 | } 50 | 51 | button_el.closest('.canvas-modal').css('display', 'none') 52 | 53 | const page_choice = button_el.data('page') 54 | $('#settings-page_type').find(`input[value='${page_choice}']`).trigger('click') 55 | } 56 | 57 | export function changePageCheck () { 58 | if (templateSelected()) { 59 | return null 60 | } else if ($('#canvas-page').html() === '' || $('#canvas-page.wrapper .tab-content').html() === '') { 61 | $('#canvas-page').html('
    ') 62 | createPage() 63 | } else { 64 | $('#warning_modal').modal() 65 | } 66 | }; 67 | 68 | export function revertPageSelection () { 69 | $(`#settings-page_type input[value="${page.name}"]`).trigger('click') 70 | } 71 | 72 | export function updateTitle (el) { 73 | const title = $(el.target).val() 74 | $('#canvas-title').html(title) 75 | $('.navbar-brand').html(title) 76 | $('.brand-link').html(title) 77 | 78 | if ($('#canvas-page').data('shinyattributes')) { 79 | const shiny_atts = $('#canvas-page').data('shinyattributes').replace(/"[^"]+"/, `"${title}"`) 80 | $('#canvas-page').attr('data-shinyattributes', shiny_atts) 81 | } 82 | 83 | if ($('#canvas-page>.main-header').data('shinyattributes')) { 84 | const shiny_atts2 = $('#canvas-page>.main-header').data('shinyattributes').replace(/"[^"]+"/, `"${title}"`) 85 | $('#canvas-page>.main-header').attr('data-shinyattributes', shiny_atts2) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/testthat/test-shinytest2.R: -------------------------------------------------------------------------------- 1 | test_that("designer app functionality works as expected", { 2 | # Don't run these tests on the CRAN build servers 3 | skip_on_cran() 4 | skip_if_not(isTRUE(nchar(chromote::find_chrome()) > 1L)) 5 | 6 | shiny_app <- designApp() 7 | app <- shinytest2::AppDriver$new(shiny_app, name = "designapp") 8 | on.exit(app$stop()) 9 | 10 | # Checking for ID uniqueness 11 | app$expect_unique_names() 12 | 13 | # Checking title and matches app title 14 | title <- app$get_value(input = "app_name") 15 | app_title <- app$get_text("#canvas-title") 16 | expect_equal(title, app_title) 17 | 18 | # Expecting app title changes on title change 19 | app$set_inputs("app_name" = "Test Name") 20 | 21 | title <- app$get_value(input = "app_name") 22 | app_title <- app$get_text("#canvas-title") 23 | expect_equal(title, app_title) 24 | 25 | # Expecting page to change on click change 26 | app$click(selector = '#settings-page_type input[value="fluidPage"]') 27 | app$wait_for_idle() 28 | ui <- app$get_value(input = "canvas-canvas") 29 | expect_true(grepl("fluidPage(", jsonToRScript(ui), fixed = TRUE)) 30 | 31 | # Expect dashboard page to populate, and generate sample R code 32 | app$click(selector = '#settings-page_type input[value="dashboardPage"]') 33 | app$wait_for_idle() 34 | ui <- app$get_value(input = "canvas-canvas") 35 | 36 | app$click(selector = "#settings-code_button") 37 | r_code <- app$get_value(output = "settings-code-code") 38 | expect_equal(r_code, jsonToRScript(ui)) 39 | app$click(selector = "#settings-code_button") 40 | 41 | # Check all components can be selected 42 | app$click(selector = "#sidebar-tab-add") 43 | 44 | stored_component_shell <- "" 45 | for (component in COMPONENTS[-1L]) { 46 | app$click(selector = paste("#sidebar", component, "header button", sep = "-")) 47 | clicked_component <- app$get_html(selector = ".component-accordion .card.active") 48 | expect_true(grepl(paste0("sidebar-", component, "-header"), clicked_component)) 49 | 50 | # Ensuring that the new component changes the preview, and is non-empty 51 | component_shell <- app$get_html(selector = "#sidebar-container") 52 | expect_true(grepl("designer-element", component_shell)) 53 | expect_true(component_shell != stored_component_shell) 54 | stored_component_shell <- component_shell 55 | } 56 | 57 | # Choose all different outputs that create IDs 58 | app$click(selector = "#sidebar-output-header button") 59 | original_outputs <- app$get_values()$output 60 | app$set_inputs("sidebar-output-type" = "plot") 61 | app$set_inputs("sidebar-output-type" = "table") 62 | app$set_inputs("sidebar-output-type" = "image") 63 | 64 | new_outputs <- app$get_values()$output 65 | expect_length(new_outputs, 3L + length(original_outputs)) 66 | 67 | # Check that UI gets added to code module 68 | app$click(selector = "#settings-code_button") 69 | expect_true(grepl("dashboardPage", app$get_value(output = "settings-code-code"))) 70 | }) 71 | -------------------------------------------------------------------------------- /srcjs/component/DateInput.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class DateInput extends Component { 4 | html = ` 5 |
    8 | 9 | $input_tag$ 10 |
    11 | ` 12 | 13 | date_input_html = ` 14 | 17 | ` 18 | 19 | date_range_input_html = ` 20 |
    21 | 24 | 25 | to 26 | 27 | 30 |
    31 | ` 32 | 33 | constructor () { 34 | super() 35 | this.updateComponent(true) 36 | } 37 | 38 | createComponent () { 39 | const label = $('#sidebar-date-label').val() 40 | 41 | let id = $('#sidebar-date-id').val() 42 | id = id === '' ? this.createID('input') : id 43 | 44 | const width = this.validateCssUnit($('#sidebar-date-width').val()) 45 | const style_str = width ? `style="width: ${width};"` : '' 46 | const width_str = width ? `, width = "${width}"` : '' 47 | 48 | const range = document.getElementById('sidebar-date-range').checked 49 | const r_func = range ? 'dateRangeInput' : 'dateInput' 50 | const date_class = range ? 'shiny-date-range-input' : 'shiny-date-input' 51 | const input_tag = range ? this.date_range_input_html : this.date_input_html 52 | 53 | return this.replaceHTMLPlaceholders(this.html, { 54 | id, 55 | label, 56 | r_func, 57 | date_class, 58 | input_tag, 59 | style_str, 60 | width_str 61 | }) 62 | }; 63 | 64 | updateComponent (update_sortable = false) { 65 | super.updateComponent(update_sortable) 66 | 67 | if (typeof (window) === 'undefined') { 68 | return null 69 | } 70 | $('.component-container').find('input').bsDatepicker() 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /R/mod_sidebar_utils.R: -------------------------------------------------------------------------------- 1 | COMPONENTS <- c( 2 | "Tab" = "tab_panel", 3 | "Header" = "header", 4 | "Tabset Panel" = "tabset", 5 | "Row" = "row", 6 | "Column" = "column", 7 | "Box/Card" = "box", 8 | "Text" = "text", 9 | "Callout" = "callout", 10 | "Blockquote" = "quote", 11 | "Input Panel" = "input_panel", 12 | "Dropdown (selectInput)" = "dropdown", 13 | "Numeric Input" = "numeric", 14 | "Text Input" = "text_input", 15 | "Textarea Input" = "textarea", 16 | "Password Input" = "password", 17 | "Slider" = "slider", 18 | "File Input" = "file", 19 | "Calendar (dateInput)" = "date", 20 | "Checkbox" = "checkbox", 21 | "Radio Buttons" = "radio", 22 | "Button" = "button", 23 | "Output" = "output", 24 | "User Box" = "user_box", 25 | "Value Box" = "value_box", 26 | "Info Box" = "info_box" 27 | ) 28 | NAVBAR_COMPONENTS <- "tab_panel" 29 | BS4_COMPONENTS <- c( 30 | "box", "user_box", "value_box", "info_box", 31 | "callout", "quote" 32 | ) 33 | 34 | #' Component Accordion Item 35 | #' 36 | #' @description 37 | #' An item to add to the sidebar that opens up the settings for the selected component 38 | #' 39 | #' @param id HTML ID to namespace on 40 | #' @param name Label to show on the closed accordion 41 | #' @param element Character string to let JS know what component has been chosen 42 | #' @param parent_id HTML ID of the accordion 43 | #' @param ... Option inputs to add when expanding the accordion item 44 | #' @param notes A list of optional notes to include at the bottom of the settings 45 | #' @param active Logical, should the accordion item be open on start? Default set to `FALSE` 46 | #' 47 | #' @return 48 | #' A `shiny.tag` element containing the component accordion item with all input settings 49 | sidebarItem <- function(id, name, element, parent_id, ..., notes = NULL, active = FALSE) { 50 | ns <- NS(id) 51 | 52 | if (element %in% BS4_COMPONENTS) { 53 | extra_class <- "bs4-item" 54 | } else if (element %in% NAVBAR_COMPONENTS) { 55 | extra_class <- "navbar-tab-item" 56 | } else { 57 | extra_class <- "" 58 | } 59 | 60 | div( 61 | class = paste("card", extra_class), 62 | div( 63 | id = ns("header"), 64 | class = "card-header", 65 | 66 | h2( 67 | class = "m-0", 68 | 69 | tags$button( 70 | class = paste("btn btn-link btn-block text-left", if (!active) "collapsed"), 71 | type = "button", 72 | `data-shinyelement` = element, 73 | `data-toggle` = "collapse", 74 | `data-target` = paste0("#", ns("body")), 75 | `aria-expanded` = tolower(active), 76 | `aria-controls` = ns("body"), 77 | name 78 | ) 79 | ) 80 | ), 81 | div( 82 | id = ns("body"), 83 | class = paste("collapse", if (active) "show"), 84 | `aria-labelledby` = ns("header"), 85 | `data-parent` = paste0("#", parent_id), 86 | 87 | div( 88 | class = "card-body", 89 | tags$form( 90 | id = ns("form"), 91 | class = "component-form", 92 | ... 93 | ), 94 | if (!is.null(notes)) { 95 | tagList( 96 | h3(class = "notes-header", "Notes"), 97 | tags$ul( 98 | class = "notes-list", 99 | lapply(notes, tags$li) 100 | ) 101 | ) 102 | } 103 | ) 104 | ) 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /R/mod_sidebar_srv.R: -------------------------------------------------------------------------------- 1 | #' @noRd 2 | SidebarModuleServer <- function(id) { 3 | moduleServer(id, function(input, output, session) { 4 | setBookmarkExclude(SIDEBAR_INPUT_IDS) 5 | module_ids <- reactiveVal(SIDEBAR_INPUT_IDS) 6 | 7 | updateSelectizeInput(session, "tab-icon", choices = getFAIcons(), selected = "", server = TRUE) 8 | updateSelectizeInput(session, "value_box-icon", choices = getFAIcons(), selected = "", server = TRUE) 9 | updateSelectizeInput(session, "info_box-icon", choices = getFAIcons(), selected = "github", server = TRUE) 10 | updateSelectizeInput(session, "tabset-icon", choices = getFAIcons(), selected = "", server = TRUE) 11 | updateSelectizeInput(session, "button-icon", choices = getFAIcons(), selected = "", server = TRUE) 12 | 13 | observeEvent(input$outputid, { 14 | if (input[["output-type"]] == "plot") { 15 | local({ 16 | output_id <- input$outputid 17 | plot_type <- input[["output-plot"]] 18 | output[[output_id]] <- shiny::renderPlot(shinipsum::random_ggplot(plot_type)) 19 | }) 20 | } else if (input[["output-type"]] == "image") { 21 | local({ 22 | output_id <- input$outputid 23 | output[[output_id]] <- shiny::renderImage(shinipsum::random_image(), deleteFile = TRUE) 24 | }) 25 | } else if (input[["output-type"]] == "table") { 26 | local({ 27 | output_id <- input$outputid 28 | output[[output_id]] <- shiny::renderDataTable( 29 | shinipsum::random_table(5L, 3L), 30 | options = list(dom = "t") 31 | ) 32 | }) 33 | } 34 | module_ids(c(module_ids(), input$outputid)) 35 | setBookmarkExclude(module_ids()) 36 | }) 37 | }) 38 | } 39 | 40 | SIDEBAR_INPUT_IDS <- c( 41 | "comments", "accordion", 42 | "box-background", "box-colour", "box-label", "box-width_num", 43 | "button-download", "button-icon", "button-id", "button-label", "button-type", "button-width", 44 | "callout-colour", "callout-label", "callout-textarea", "callout-width_num", 45 | "checkbox-id", "checkbox-label", "checkbox-width", 46 | "column-offset", "column-width_num", 47 | "date-id", "date-label", "date-range", "date-width", 48 | "dropdown-id", "dropdown-label", "dropdown-width", 49 | "file-id", "file-label", "file-width", 50 | "header-tag", "header-text", 51 | "info_box-background", "info_box-fill", "info_box-icon", "info_box-label", "info_box-value", "info_box-width_num", 52 | "text_input-id", "text_input-label", "text_input-type", "text_input-width", 53 | "password-id", "password-label", "password-type", "password-width", 54 | "textarea-id", "textarea-label", "textarea-type", "textarea-width", 55 | "numeric-id", "numeric-label", "numeric-type", "numeric-width", 56 | "output-height", "output-id", "output-inline", "output-plot", "output-textarea", "output-type", "output-width", 57 | "quote-colour", "quote-textarea", 58 | "radio-choices", "radio-id", "radio-inline", "radio-label", "radio-type", "radio-width", 59 | "slider-id", "slider-label", "slider-range", "slider-type", "slider-width", 60 | "tab-add", "tab-delete", "tab-icon", "tab-name", "tab-value", 61 | "tabset-add", "tabset-colour", "tabset-delete", "tabset-icon", "tabset-label", 62 | "tabset-name", "tabset-type", "tabset-value", "tabset-width_num", 63 | "text-tag", "text-textarea", 64 | "user_box-background", "user_box-colour", "user_box-label", "user_box-type", "user_box-width_num", 65 | "value_box-background", "value_box-icon", "value_box-label", "value_box-value", "value_box-width_num" 66 | ) 67 | -------------------------------------------------------------------------------- /R/app_ui.R: -------------------------------------------------------------------------------- 1 | #' \{designer\} application User-Interface 2 | #' 3 | #' @param request Internal parameter for `{shiny}`. 4 | #' 5 | #' @noRd 6 | appUI <- function(request) { 7 | ui <- tagList( 8 | addGolemExternalResources(), 9 | 10 | tags$header( 11 | h1("{designer} - Design your UI"), 12 | tags$button( 13 | id = "help", 14 | class = "btn btn-outline-dark action-button guide-button", 15 | "Help" 16 | ) 17 | ), 18 | 19 | fluidPage( 20 | title = "Shiny UI Designer", 21 | theme = bslib::bs_theme(version = 4L), 22 | lang = "en", 23 | 24 | warningModal( 25 | id = "warning_modal", 26 | text = "Changing page type will clear all contents of your design. Do you wish to continue?", 27 | confirm_id = "confirm_reset", 28 | confirm_text = "Yes", 29 | cancel_id = "cancel_reset", 30 | cancel_text = "No" 31 | ), 32 | warningModal( 33 | id = "clear_modal", 34 | text = "By confirming you will clear all contents of the page. Do you wish to continue?", 35 | confirm_id = "confirm_clear", 36 | confirm_text = "Confrim", 37 | cancel_id = "cancel_clear", 38 | cancel_text = "Cancel" 39 | ), 40 | 41 | SettingsModUI("settings"), 42 | 43 | fluidRow( 44 | column( 45 | width = 3L, 46 | class = "d-flex flex-column justify-content-between px-2", 47 | SidebarModUI("sidebar") 48 | ), 49 | column( 50 | width = 9L, 51 | class = "px-2", 52 | CanvasModUI("canvas") 53 | ) 54 | ) 55 | ) 56 | ) 57 | 58 | attr(ui, "lang") <- "en" 59 | ui 60 | } 61 | 62 | #' Add external Resources to the Application 63 | #' 64 | #' @description 65 | #' This function is internally used to add external 66 | #' resources inside the Shiny application. 67 | #' 68 | #' @return 69 | #' A series of tags that are included in \code{} 70 | #' 71 | #' @noRd 72 | addGolemExternalResources <- function() { 73 | golem::add_resource_path("www", appSys("app/www")) 74 | golem::add_resource_path("images", appSys("app/images")) 75 | 76 | ui_head <- tags$head( 77 | golem::favicon(), 78 | golem::bundle_resources( 79 | path = appSys("app/www"), 80 | app_title = "Shiny UI Designer", 81 | name = "designer", 82 | version = packageVersion("designer") 83 | ), 84 | ionRangeSliderDependency(), 85 | datePickerDependency(), 86 | dataTableDependency, 87 | cicerone::use_cicerone(), 88 | 89 | tags$meta(name = "description", content = "Create Wireframes of the UI of shiny applications"), 90 | tags$meta(name = "keywords", content = "R, shiny, designer, prototype, wireframe"), 91 | tags$meta(name = "author", content = "Ashley Baldry") 92 | ) 93 | 94 | htmltools::attachDependencies( 95 | ui_head, 96 | list( 97 | htmltools::htmlDependency( 98 | name = "Sortable", 99 | version = "1.14.0", 100 | src = "srcjs/sortable", 101 | script = "Sortable.min.js", 102 | package = "designer" 103 | ), 104 | htmltools::htmlDependency( 105 | name = "bs-custom-file-input", 106 | version = "1.3.4", 107 | src = "srcjs/bs-custom-file-input", 108 | script = "bs-custom-file-input.min.js", 109 | package = "designer" 110 | ) 111 | ) 112 | ) 113 | } 114 | 115 | ionRangeSliderDependency <- getFromNamespace("ionRangeSliderDependency", "shiny") 116 | datePickerDependency <- getFromNamespace("datePickerDependency", "shiny") 117 | dataTableDependency <- getFromNamespace("dataTableDependency", "shiny") 118 | -------------------------------------------------------------------------------- /srcjs/component/Output.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Output extends Component { 4 | types = [ 5 | { value: 'text', label: 'Text', css_class: 'text-output-element shiny-text-output', r_func: 'textOutput', placeholder: 'Text Output: ' }, 6 | { value: 'verbatim', label: 'Verbatim Text', css_class: 'verbatimtext-output-element shiny-text-output', r_func: 'verbatimTextOutput', placeholder: 'Verbatim Text Output: ' }, 7 | { value: 'plot', label: 'Plot', css_class: 'plot-output-element shiny-plot-output', r_func: 'plotOutput' }, 8 | { value: 'image', label: 'Image', css_class: 'image-output-element shiny-image-output', r_func: 'imageOutput' }, 9 | { value: 'table', label: 'Table', css_class: 'table-output-element shiny-datatable-output', r_func: 'DT::DTOutput' }, 10 | { value: 'html', label: 'HTML', css_class: 'html-output-element shiny-html-output', r_func: 'uiOutput', placeholder: 'Placeholder for HTML Output' } 11 | ] 12 | 13 | html = ` 14 | <$html_tag$ $id_str$ class="designer-element output-element $css_class$" 15 | style="$style_str$" 16 | data-shinyfunction="$r_func$" 17 | data-shinyattributes="outputId = "$id$"$inline_str$$dim_str$"> 18 | $output_tag$ 19 | 20 | ` 21 | 22 | constructor () { 23 | super() 24 | this.updateComponent(true) 25 | } 26 | 27 | createComponent () { 28 | const label = $('#sidebar-output-label').val() 29 | 30 | let id = $('#sidebar-output-id').val() 31 | id = id === '' ? this.createID('output') : id 32 | 33 | const output_type = $('#sidebar-output-type').val() 34 | const output_info = this.types.find(x => x.value === output_type) 35 | if (!output_info) return 36 | const r_func = output_info.r_func 37 | let html_tag = output_type === 'verbatim' ? 'pre' : 'div' 38 | const css_class = output_info.css_class 39 | 40 | let id_str = '' 41 | if (['plot', 'image', 'table'].includes(output_type)) { 42 | const designer_id = this.createID('output') 43 | Shiny.setInputValue('sidebar-outputid', designer_id) 44 | id_str = `id="sidebar-${designer_id}"` 45 | } 46 | 47 | const inline = document.getElementById('sidebar-output-inline').checked 48 | const inline_str = inline && !['verbatim', 'table'].includes(output_type) ? ', inline = TRUE' : '' 49 | if (inline_str !== '') { 50 | html_tag = 'span' 51 | } 52 | 53 | let dim_str = '' 54 | let style_str = '' 55 | 56 | if (['plot', 'image'].includes(output_type)) { 57 | const width = this.validateCssUnit($('#sidebar-output-width').val(), '100%') 58 | style_str = `width: ${width};` 59 | dim_str = width === '100%' ? '' : `, width = "${width}"` 60 | 61 | const height = this.validateCssUnit($('#sidebar-output-height').val(), '400px') 62 | style_str = style_str + ` height: ${height};` 63 | dim_str = dim_str + (height === '400px' ? '' : `, height = "${height}"`) 64 | } 65 | 66 | let output_tag = '' 67 | if (output_info.placeholder) { 68 | if (output_type === 'html') { 69 | output_tag = `${output_info.placeholder}` 70 | } else { 71 | output_tag = `${output_info.placeholder} ${$('#sidebar-output-textarea').val()}` 72 | } 73 | } 74 | 75 | return this.replaceHTMLPlaceholders(this.html, { 76 | html_tag, 77 | id, 78 | label, 79 | id_str, 80 | r_func, 81 | css_class, 82 | style_str, 83 | dim_str, 84 | inline_str, 85 | output_tag 86 | }) 87 | }; 88 | 89 | updateComponent (update_sortable = false) { 90 | super.updateComponent(update_sortable) 91 | 92 | if (typeof (window) === 'undefined') { 93 | return null 94 | } 95 | Shiny.bindAll() 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /R/mod_template_utils.R: -------------------------------------------------------------------------------- 1 | #' Store Saved Prototype 2 | #' 3 | #' @description 4 | #' Saves the current template in the designer cache, along with metadata 5 | #' 6 | #' @param html Character string of the HTML that is present in the canvas 7 | #' @param page Type of page selected for the template (used to restore correct value on restart) 8 | #' @param title Short title of the template 9 | #' @param desc Longer description of the template 10 | #' @param user Person who created the template 11 | #' 12 | #' @importFrom utils read.csv write.csv write.table 13 | #' @noRd 14 | save_template <- function(html, page = NULL, title = NULL, desc = NULL, user = NULL, 15 | session = shiny::getDefaultReactiveDomain()) { 16 | cache_dir <- find_cache_dir() 17 | template_index <- get_template_index() 18 | 19 | template_id <- create_random_id() 20 | while (template_id %in% template_index$id) { 21 | template_id <- create_random_id() 22 | } 23 | 24 | template_dir <- file.path(cache_dir, template_id) 25 | dir.create(template_dir, showWarnings = FALSE) 26 | cat(paste0(html, "\n"), file = file.path(template_dir, "template.html")) 27 | 28 | write.table( 29 | data.frame(id = template_id, page = page, title = title, user = user, description = desc), 30 | file.path(cache_dir, "index.csv"), 31 | sep = ",", 32 | append = TRUE, 33 | col.names = FALSE, 34 | row.names = FALSE 35 | ) 36 | 37 | template_id 38 | } 39 | 40 | create_random_id <- function(n = 10L) { 41 | paste0(sample(letters, n, replace = TRUE), collapse = "") 42 | } 43 | 44 | update_template <- function(html, id, session = shiny::getDefaultReactiveDomain()) { 45 | cache_dir <- find_cache_dir() 46 | cat(paste0(html, "\n"), file = file.path(cache_dir, id, "template.html")) 47 | } 48 | 49 | read_template <- function(id) { 50 | cache_dir <- find_cache_dir() 51 | paste0(readLines(file.path(cache_dir, id, "template.html")), collapse = "\n") 52 | } 53 | 54 | delete_template <- function(id) { 55 | cache_dir <- find_cache_dir() 56 | template_index <- get_template_index() 57 | 58 | if (id %in% template_index$id) { 59 | unlink(file.path(cache_dir, id), recursive = TRUE) 60 | template_index <- template_index[template_index$id != id, ] 61 | 62 | write.csv( 63 | template_index, 64 | file.path(cache_dir, "index.csv"), 65 | row.names = FALSE 66 | ) 67 | } 68 | } 69 | 70 | #' Take Template Screenshot 71 | #' 72 | #' @noRd 73 | take_screenshot <- function(screenshot_dir, session = shiny::getDefaultReactiveDomain()) { 74 | if (!dir.exists(screenshot_dir)) { 75 | dir.create(screenshot_dir) 76 | } 77 | 78 | screenshot_png <- list.files(screenshot_dir, pattern = ".png") 79 | if (length(screenshot_png) >= 1L) { 80 | file.remove(screenshot_png) 81 | } 82 | 83 | session$sendCustomMessage("prepare_canvas_screenshot", list()) 84 | Sys.sleep(0.05) 85 | shinyscreenshot::screenshot( 86 | selector = "#canvas-page", 87 | download = FALSE, 88 | server_dir = screenshot_dir 89 | ) 90 | Sys.sleep(0.05) 91 | session$sendCustomMessage("revert_canvas_screenshot", list()) 92 | } 93 | 94 | #' Template Index File 95 | #' 96 | #' @description 97 | #' To keep track of all templates, a csv file is created within the local cache 98 | #' to store the name, author and description of the template, along with a unique 99 | #' ID. 100 | #' 101 | #' @noRd 102 | get_template_index <- function(index_file = file.path(find_cache_dir(), "index.csv")) { 103 | if (!file.exists(index_file)) { 104 | create_template_index(index_file) 105 | } 106 | 107 | read.csv(index_file) 108 | } 109 | 110 | #' @noRd 111 | create_template_index <- function(index_file = file.path(find_cache_dir(), "index.csv")) { 112 | if (file.exists(index_file)) { 113 | index_file 114 | } else { 115 | writeLines("id,page,title,user,description", index_file) 116 | index_file 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/testthat/test-json-to-r.R: -------------------------------------------------------------------------------- 1 | test_that("HTML -> R: Empty list returns nothing", { 2 | expect_equal(htmlToRScript(NULL), "") 3 | expect_equal(htmlToRScript(list()), "") 4 | }) 5 | 6 | test_that("HTML -> R: Works with valid list", { 7 | ui_script <- htmlToRScript( 8 | list( 9 | r_function = "basicPage", 10 | children = list( 11 | list( 12 | r_function = "sliderInput", 13 | r_arguments = "inputId = \"slider_twecttskbi\", label = \"Label\", min = 0, max = 10, value = 5" 14 | ) 15 | ) 16 | ) 17 | ) 18 | 19 | expect_type(ui_script, "character") 20 | expect_true(grepl("inputId = \"slider_twecttskbi\",\n", ui_script)) 21 | }) 22 | 23 | test_that("HTML -> R: Works with module", { 24 | ui_script <- htmlToRScript( 25 | module_name = "Test", 26 | list( 27 | r_function = "basicPage", 28 | children = list( 29 | list( 30 | r_function = "sliderInput", 31 | r_arguments = "inputId = \"slider_twecttskbi\", label = \"Label\", min = 0, max = 10, value = 5" 32 | ) 33 | ) 34 | ) 35 | ) 36 | 37 | expect_type(ui_script, "character") 38 | expect_true(grepl("#' Test Module", ui_script)) 39 | expect_true(grepl("TestUI <-", ui_script)) 40 | }) 41 | 42 | test_that("JSON -> R: NULL returns empty string", { 43 | expect_equal(jsonToRScript(NULL), "") 44 | }) 45 | 46 | test_that("JSON -> R: Invalid JSON returns NA", { 47 | expect_message(jsonToRScript("")) 48 | expect_equal(suppressMessages(jsonToRScript("")), NA_character_) 49 | }) 50 | 51 | test_that("JSON -> R: Empty JSON object returns empty string", { 52 | expect_equal(jsonToRScript("{}"), "") 53 | }) 54 | 55 | test_that("JSON -> R: JSON list returns valid R code", { 56 | ui_script <- jsonToRScript('{"r_function": "fluidPage"}') 57 | expect_true(grepl("fluidPage", ui_script)) 58 | }) 59 | 60 | test_that("JSON -> R: Arguments appear on own line", { 61 | ui_script <- jsonToRScript( 62 | '{"tagName":"div", 63 | "r_function":"sliderInput", 64 | "r_arguments":"inputId = \\"slider_twecttskbi\\", label = \\"Label\\", min = 0, max = 10, value = 5", 65 | "text":"", 66 | "htmlclass":"designer-element form-group shiny-input-container", 67 | "children":[] 68 | }' 69 | ) 70 | expect_type(ui_script, "character") 71 | expect_true(grepl("inputId = \"slider_twecttskbi\"", ui_script, fixed = TRUE)) 72 | }) 73 | 74 | test_that("JSON -> R: Text is included correctly", { 75 | ui_script <- jsonToRScript( 76 | '{"tagName":"p", 77 | "r_function":"p", 78 | "text":"Sample text", 79 | "htmlclass":"", 80 | "children":[] 81 | }' 82 | ) 83 | expect_type(ui_script, "character") 84 | expect_identical(ui_script, "p(\n \"Sample text\"\n)") 85 | }) 86 | 87 | test_that("JSON -> R: Vector appears on single line", { 88 | ui_script <- jsonToRScript( 89 | '{"r_function": "fluidPage", "r_arguments": "x = c(1, 2), y = c(3, 4)"}' 90 | ) 91 | expect_true(grepl("x = c(1, 2),\n", ui_script, fixed = TRUE)) 92 | }) 93 | 94 | test_that("JSON -> R: Comment appears first", { 95 | ui_script <- jsonToRScript( 96 | '{"r_function": "fluidPage", "r_arguments": "x = c(1, 2), y = c(3, 4)", "r_comments": "Test"}' 97 | ) 98 | expect_true(grepl("^# Test", ui_script)) 99 | }) 100 | 101 | test_that("JSON -> R: Multi-line comment works", { 102 | ui_script <- jsonToRScript( 103 | '{"r_function": "fluidPage", "r_comments": "Test\\nTest"}' 104 | ) 105 | expect_true(grepl("^# Test\n# Test", ui_script)) 106 | }) 107 | 108 | test_that("JSON -> R: Results match HTML -> R", { 109 | ui_script <- ' 110 | {"tagName":"div", 111 | "r_function":"sliderInput", 112 | "r_arguments":"inputId = \\"slider_twecttskbi\\", label = \\"Label\\", min = 0, max = 10, value = 5","text":"", 113 | "htmlclass":"designer-element form-group shiny-input-container", 114 | "children":[] 115 | } 116 | ' 117 | 118 | ui_list <- list( 119 | r_function = "sliderInput", 120 | r_arguments = "inputId = \"slider_twecttskbi\", label = \"Label\", min = 0, max = 10, value = 5" 121 | ) 122 | 123 | expect_identical(jsonToRScript(ui_script), htmlToRScript(ui_list)) 124 | }) 125 | -------------------------------------------------------------------------------- /srcjs/component/SliderInput.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class SliderInput extends Component { 4 | html = ` 5 |
    8 | 9 | 14 |
    15 | ` 16 | 17 | constructor () { 18 | super() 19 | this.updateComponent(true) 20 | } 21 | 22 | createComponent () { 23 | const label = $('#sidebar-slider-label').val() 24 | 25 | let id = $('#sidebar-slider-id').val() 26 | id = id === '' ? this.createID('slider') : id 27 | 28 | const format = $('#sidebar-slider-type').val() 29 | 30 | const width = this.validateCssUnit($('#sidebar-slider-width').val()) 31 | const style_str = width ? `style="width: ${width};"` : '' 32 | const width_str = width ? `, width = "${width}"` : '' 33 | 34 | const ranged = document.getElementById('sidebar-slider-range').checked 35 | const values = this.getValues(format, ranged) 36 | const range_attr = ranged ? 'data-type="double" data-drag-interval="true" data-to="$to$"' : '' 37 | 38 | return this.replaceHTMLPlaceholders(this.html, { 39 | range_attr, 40 | id, 41 | label, 42 | format, 43 | min: values.min, 44 | max: values.max, 45 | step: values.step, 46 | from: values.from, 47 | to: values.to, 48 | style_str, 49 | width_str, 50 | value_str: values.value_str, 51 | time_format: values.time_format 52 | }) 53 | }; 54 | 55 | getValues (format, range = false) { 56 | if (format === 'number') { 57 | return { 58 | step: 1, 59 | min: 0, 60 | max: 10, 61 | from: 5, 62 | to: 7, 63 | value_str: `, min = 0, max = 10, value = ${range ? 'c(5, 7)' : 5}` 64 | } 65 | } 66 | 67 | const curr_date = new Date() 68 | if (format === 'date') { 69 | curr_date.setHours(0, 0, 0, 0) 70 | } 71 | const curr_time = curr_date.getTime() 72 | const step = format === 'date' ? 1000 * 60 * 60 * 24 : 1000 73 | 74 | const min = curr_time - 5 * step 75 | const max = curr_time + 5 * step 76 | const from = curr_time 77 | const to = curr_time + 2 * step 78 | 79 | const r_datefunc = format === 'date' ? 'Sys.Date()' : 'Sys.time()' 80 | const r_mult = format === 'date' ? '' : '000' 81 | const input_value_str = range ? `"c(${r_datefunc}, ${r_datefunc} + 2${r_mult})"` : r_datefunc 82 | 83 | return { 84 | step, 85 | min, 86 | max, 87 | from, 88 | to, 89 | time_format: format === 'date' ? '%F' : '%F %T', 90 | value_str: `, min = ${r_datefunc} - 5${r_mult}, max = ${r_datefunc} + 5${r_mult}, value = ${input_value_str}` 91 | } 92 | } 93 | 94 | updateComponent (update_sortable = false) { 95 | super.updateComponent(update_sortable) 96 | 97 | if (typeof (window) === 'undefined') { 98 | return null 99 | } 100 | const slider_type = $('#sidebar-slider-type').val() 101 | $('.component-container').find('input').ionRangeSlider({ prettify: this.getSliderPrettifier(slider_type) }) 102 | }; 103 | 104 | getSliderPrettifier (type) { 105 | if (type === 'date') { 106 | return function (num) { 107 | const sel_date = new Date(num) 108 | return sel_date.getFullYear() + '-' + (sel_date.getMonth() + 1) + '-' + sel_date.getDate() 109 | } 110 | } else if (type === 'datetime') { 111 | return function (num) { 112 | const sel_date = new Date(num) 113 | return sel_date.getFullYear() + '-' + (sel_date.getMonth() + 1) + '-' + sel_date.getDate() + ' ' + 114 | sel_date.getHours() + ':' + sel_date.getMinutes() + ':' + sel_date.getSeconds() 115 | } 116 | } else { 117 | return null 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /R/json_to_rscript.R: -------------------------------------------------------------------------------- 1 | #' Convert JSON to R Calls 2 | #' 3 | #' @description 4 | #' Using the JSON string generated from the canvas, convert into a list that can be easily parsed by R 5 | #' into a script to recreate the UI designed in the app 6 | #' 7 | #' @param json A string containing JSON code of the "App UI" page 8 | #' @param module_name Optional string the allows the function to be written as a module 9 | #' @param app_type Structure of the application. Either `app`, with an app.R/ui.R and server.R, 10 | #' `golem` or `rhino` with the relevant project structure. 11 | #' 12 | #' @return A string that can be written to a \code{ui.R} file 13 | #' 14 | #' @noRd 15 | jsonToRScript <- function(json, module_name = NULL, app_type = c("app", "golem", "rhino")) { 16 | if (is.null(json)) return("") 17 | 18 | valid_json <- jsonlite::validate(json) 19 | 20 | if (valid_json) { 21 | app_type <- match.arg(app_type) 22 | html_list <- jsonlite::fromJSON(json, simplifyDataFrame = FALSE) 23 | htmlToRScript(html_list, module_name = module_name, app_type = app_type) 24 | } else { 25 | message(attr(valid_json, "err"), "Returning NA") 26 | NA_character_ 27 | } 28 | } 29 | 30 | #' Convert HTML Info to R Calls 31 | #' 32 | #' @description 33 | #' Recursively looking into a list of HTML information to create R code that will produce the HTML seen in the app. 34 | #' 35 | #' @param html_list A list containing information about the tags, relevant R functions and extra arguments to 36 | #' give to each HTML tag 37 | #' @param indent The number of spaces to indent each subsequent call 38 | #' @param module_name Optional string the allows the function to be written as a module 39 | #' 40 | #' @return A string that can be written to a \code{ui.R} file 41 | #' 42 | #' @noRd 43 | htmlToRScript <- function(html_list, indent = 0L, module_name = NULL, app_type = c("app", "golem", "rhino")) { 44 | if (is.null(html_list$r_function)) return("") 45 | app_type <- match.arg(app_type) 46 | 47 | module_home <- indent == 0L && (is.character(module_name) || app_type != "app") 48 | indent_space <- paste0(rep(" ", indent), collapse = "") 49 | indent_text_space <- paste0(rep(" ", indent + 2L), collapse = "") 50 | 51 | if ("children" %in% names(html_list) && length(html_list$children) > 0L) { 52 | sub_indent <- indent + if (module_home) 4L else 2L 53 | sub_rfuncs <- lapply(html_list$children, htmlToRScript, indent = sub_indent) 54 | sub_rfuncs <- paste0(paste(sub_rfuncs, collapse = ",\n"), "\n") 55 | } else { 56 | sub_rfuncs <- "" 57 | } 58 | 59 | if (is.null(html_list$text) || html_list$text == "") { 60 | html_text <- "" 61 | } else { 62 | html_text <- paste0( 63 | indent_text_space, 64 | "\"", html_list$text, "\"", 65 | if (sub_rfuncs == "") "" else ",", 66 | "\n" 67 | ) 68 | } 69 | 70 | if (is.null(html_list$r_arguments)) { 71 | rfunc_arguments <- "" 72 | } else { 73 | rfunc_arguments <- paste0( 74 | indent_text_space, 75 | gsub(",(?![^\\(]+\\)) ", paste0(",\n", indent_text_space), html_list$r_arguments, perl = TRUE), 76 | if (sub_rfuncs == "" && html_text == "") "" else ",", 77 | "\n" 78 | ) 79 | } 80 | 81 | if (is.null(html_list$r_comments)) { 82 | r_comments <- "" 83 | } else { 84 | r_comments <- paste0(indent_space, "# ", strsplit(html_list$r_comments, "\n")[[1L]], "\n", collapse = "") 85 | } 86 | 87 | if (module_home && is.character(module_name)) { 88 | r_comments <- paste0("#' ", module_name, " Module\n#' @export\n") 89 | if (app_type == "rhino") { 90 | pkgs <- c("shiny", if (html_list$r_function == "dashboardPage") "shinydashboard") 91 | r_comments <- paste0("box::use(", toString(pkgs), ")\n\n", r_comments) 92 | } 93 | 94 | rfunc <- paste0( 95 | gsub("\\W", "", tools::toTitleCase(module_name)), 96 | "UI <- function(id) {\n tagList(\n" 97 | ) 98 | rfunc_arguments <- NULL 99 | rfunc_end <- " )\n}" 100 | } else if (module_home) { 101 | r_comments <- paste0("#' Application UI \n#' @export\n") 102 | if (app_type == "rhino") { 103 | pkgs <- c("shiny", if (html_list$r_function == "dashboardPage") "shinydashboard") 104 | r_comments <- paste0("box::use(", toString(pkgs), ")\n\n", r_comments) 105 | } 106 | 107 | rfunc <- paste0("AppUI <- function(id) {\n ", html_list$r_function, "(\n") 108 | rfunc_arguments <- NULL 109 | rfunc_end <- " )\n}" 110 | } else { 111 | rfunc <- paste0(indent_space, html_list$r_function, "(\n") 112 | rfunc_end <- paste0(indent_space, ")") 113 | } 114 | 115 | paste0( 116 | r_comments, 117 | rfunc, 118 | rfunc_arguments, 119 | html_text, 120 | sub_rfuncs, 121 | rfunc_end, 122 | collapse = "" 123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) 4 | [![Codecov test coverage](https://codecov.io/gh/ashbaldry/designer/branch/main/graph/badge.svg)](https://app.codecov.io/gh/ashbaldry/designer?branch=main) 5 | [![R-CMD-check](https://github.com/ashbaldry/designer/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/ashbaldry/designer/actions/workflows/R-CMD-check.yaml) 6 | 7 | 8 | # {designer} 9 | 10 | `{designer}` is intended to make the initial generation of a UI wireframe of a shiny application as quick and simple as possible. 11 | 12 | The package contains a `shiny` application that enables the user to build the UI of a `shiny` application by drag and dropping several `shiny` components - such as inputs, outputs and buttons - into one of the available pages in the `{shiny}` package. Once finalised, the R code used to generate the UI can be copied or downloaded to a `ui.R` file, and then the rest of the application like the server logic and styling can be built by the developer. 13 | 14 | The drag-and-drop nature of the application means that it is easy for both R and non-R users to collaborate in designing the UI of their shiny application. Comments can be added to any component so that it is simple to remember what should be included for each input/output. 15 | 16 | ## Installation 17 | 18 | Install from [CRAN](https://cran.r-project.org/package=designer) with: 19 | 20 | ``` r 21 | install.packages("designer") 22 | ``` 23 | 24 | Or install the development version from [GitHub](https://github.com/ashbaldry/designer) with: 25 | 26 | ``` r 27 | devtools::install_github("ashbaldry/designer") 28 | ``` 29 | 30 | The application is also available on-line through [shinyapps.io](https://ashbaldry.shinyapps.io/designer). 31 | 32 | ## Usage 33 | 34 | To open the `{designer}` application and create your own UI, run the following code: 35 | 36 | ``` r 37 | designer::designApp() 38 | ``` 39 | 40 | Alternatively, you can launch the addin via the RStudio menu. 41 | 42 | ![](https://raw.githubusercontent.com/ashbaldry/designer/main/man/figures/example_app.gif) 43 | 44 | Once opened, create the application as required until you are happy with the layout of the application, then copy the code to the relevant R file 45 | 46 | ![](man/figures/example_app_filled.jpeg) 47 | 48 | ``` r 49 | # ui.R 50 | 51 | bootstrapPage( 52 | title = "Shiny Application", 53 | theme = bslib::bs_theme(4), 54 | h1( 55 | "My shiny application" 56 | ), 57 | inputPanel( 58 | selectInput( 59 | inputId = "dropdown_gxc2o1ekgb", 60 | label = "Label", 61 | choices = "..." 62 | ), 63 | selectInput( 64 | inputId = "dropdown_azset57v65", 65 | label = "Label", 66 | choices = "..." 67 | ), 68 | selectInput( 69 | inputId = "dropdown_itgcle8yze", 70 | label = "Label", 71 | choices = "..." 72 | ) 73 | ), 74 | fluidRow( 75 | column( 76 | width = 6, 77 | # Bar plot 78 | plotOutput( 79 | outputId = "plot_zvu8c9upbu", 80 | height = "200px" 81 | ) 82 | ), 83 | column( 84 | width = 6, 85 | # Line chart 86 | plotOutput( 87 | outputId = "plot_qsmfr0lp57", 88 | height = "200px" 89 | ) 90 | ) 91 | ) 92 | ) 93 | ``` 94 | 95 | ## Docker 96 | 97 | Optionally, you can also build a Dockerized version of the app: 98 | 99 | ``` 100 | sudo docker build -t designer -f Dockerfile . 101 | ``` 102 | 103 | After building the docker image (which should take a while) use the command: 104 | 105 | ``` 106 | docker run -p 80:80 designer 107 | ``` 108 | 109 | Depending on your Docker set-up, the exposed application may be available under: http://localhost:80 110 | 111 | ## Sharing Designs 112 | 113 | Once you are ready with your initial design, you can share it with others using Code -> Share. This will generate a URL that when opened by another person (or yourself in the future) will show the saved state of the design and then can be added onto and saved again - this will generate a new URL to share. 114 | 115 | **NB** For bookmarking to work, the server the `{designer}` application sits on must be configured to allow sessions to be saved on the disk. As the saved state only saves the HTML and a couple of inputs, each saved state is generally only a couple of kilobytes. 116 | 117 | ## Notes 118 | 119 | Certain inputs will only include default values and not fully customisable; this is intentional as they are likely to change throughout development and therefore not something that is required at this time of the development process. 120 | -------------------------------------------------------------------------------- /vignettes/designer.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Prototyping Your UI with `designer`" 3 | date: "`r Sys.Date()`" 4 | output: rmarkdown::html_vignette 5 | vignette: > 6 | %\VignetteIndexEntry{Prototyping Your UI with `designer`} 7 | %\VignetteEngine{knitr::rmarkdown} 8 | %\VignetteEncoding{UTF-8} 9 | --- 10 | 11 | `designer` is intended to make the initial generation of a UI wireframe of a shiny application as quick and simple as possible. 12 | 13 | The package contains a `shiny` application that enables the user to build the UI of a `shiny` application by drag and dropping several `shiny` components - such as inputs, outputs and buttons - into one of the available pages in the `{shiny}` package. Once finalised, the R code used to generate the UI can be copied or downloaded to a `ui.R` file, and then the rest of the application like the server logic and styling can be built by the developer. 14 | 15 | The drag-and-drop nature of the application means that it is easy for both R and non-R users to collaborate in designing the UI of their shiny application. Comments can be added to any component so that it is simple to remember what should be included for each input/output. 16 | 17 | To run the application, use `designer::designApp()` or select "Shiny UI Builder" in the Addins. 18 | 19 | --- 20 | 21 | ## Application 22 | 23 | There are several steps in creating the desired application UI: 24 | 25 | ### Page 26 | 27 | First is the choice of page. 28 | 29 | - __Standard Page__ is the most commonly used page in shiny applications. 30 | - __Navigation Bar Page__ is useful when creating multi-page applications. 31 | - __Dashboard Page__ can be used to replicate the `{bs4Dash}` dashboard page. 32 | - __Fluid Page__ takes advantage of the rows and columns to align content. 33 | - __Fill Page__ and __Fixed Page__ are included for cases where the developer has more broad knowledge of HTML and CSS and will adapt the application more once the wireframe is created. 34 | 35 | ### Components 36 | 37 | Next is adding the components to the page. A list of available components mentioned below. When creating components the `{shiny}` function parameters that can affect the look/layout of the UI (e.g. width and labels) are available to customise, but the more server-logic related parameters (e.g. dropdown choices) are left to the application developer later on. 38 | 39 | | Component | `{shiny}` Function | Description | 40 | | -------------- | --------------------------- | ---------------------------------------------- | 41 | | Tab | `tabPanel` | (`navbarPage` only) Adding/Removing a tab | 42 | | Header | `h1` to `h6` | | 43 | | Row | `fluidRow` | | 44 | | Column | `column` | | 45 | | Text | `p`, `ol`, `ul` | Adding text or a list to a page | 46 | | Input Panel | `inputPanel` | | 47 | | Dropdown | `selectInput` | | 48 | | Input | `textInput`, `numericInput`, `textAreaInput`, `passwordInput` | | 49 | | Slider | `sliderInput` | | 50 | | File Input | `fileInput` | | 51 | | Calendar | `dateInput`, `dateRangeInput` | | 52 | | Checkbox | `checkboxInput` | | 53 | | Radio Buttons | `checkboxInput` | | 54 | | Button | `actionButton` | | 55 | | Output | `textOutput`, `verbatimTextOutput`, `plotOutput`, `imageOutput`, `DTOutput`, `uiOutput` | | 56 | 57 | #### `bs4Dash` Components 58 | 59 | | Component | `{bs4Dash}` Function | Description | 60 | | -------------- | --------------------------- | ---------------------------------------------- | 61 | | Tab | `tabItem`, `bs4TabItem` | Adding/Removing a tab | 62 | | Box/Card | `box`, `bs4Card` | | 63 | | User Box/Card | `userBox`, `bs4UserCard` | | 64 | | Info Box | `infoBox`, `bs4InfoBox` | | 65 | | Value Box | `valueBox`, `bs4ValueBox` | | 66 | | Block Quote | `blockQuote` | | 67 | | Callout | `callout` | | 68 | 69 | #### Notes 70 | 71 | A few layout rules have been implemented into the application to try and match the Bootstrap UI framework that aren't always checked in `{shiny}`. 72 | 73 | - Columns can only be added to rows. This matches the [grid system](https://getbootstrap.com/docs/4.6/layout/grid/) Bootstrap have used (which is based off flexbox). 74 | - For a similar reason rows are the only component that can be directly added to columns. Anything can be added into a row (even more columns) 75 | - The only components that are allowed in input panels are inputs and buttons. 76 | 77 | ### Saving 78 | 79 | Once the wireframe is complete, then there is the ability to save the code, either by downloading the file or copying the code. There is also the opportunity to take a screenshot to annotate further if required. 80 | 81 | ## Sharing Designs 82 | 83 | Alternatively you can share it with others using Templates -> Save -> Share. This will generate a URL that when opened by another person (or yourself in the future) will show the saved state of the design and then can be added onto and saved again - this will generate a new URL to share. 84 | 85 | ### Extras 86 | 87 | There are some development tools that have been enabled upon start-up that can be removed to preview the UI as the end user would see the application, such as borders around all components, colouring some empty components like columns and rows, and removing component names from the UI. 88 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards 42 | of acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies 54 | when an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail 56 | address, posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at [INSERT CONTACT 63 | METHOD]. All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series of 85 | actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or permanent 92 | ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within the 112 | community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, 118 | available at https://www.contributor-covenant.org/version/2/0/ 119 | code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at https:// 128 | www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /R/mod_template_srv.R: -------------------------------------------------------------------------------- 1 | #' @noRd 2 | TemplateModuleServer <- function(id, html, page) { 3 | moduleServer(id, function(input, output, session) { 4 | ns <- session$ns 5 | shared_template_id <- reactiveVal() 6 | 7 | #### Bookmarking #### 8 | setBookmarkExclude(c( 9 | "title", "description", "author", 10 | "save", "save_share", 11 | "overwrite", "overwrite_share", 12 | "existing_template", "save_button", "select", 13 | "delete", "confirm_delete", "search" 14 | )) 15 | 16 | onBookmark(function(state) { 17 | state$values$template <- shared_template_id() 18 | }) 19 | onRestore(function(state) { 20 | session$sendCustomMessage( 21 | "runjs", 22 | list( 23 | script = paste0( 24 | "document.querySelector(\".template-option[data-value='", state$values$template, "']\").click()" 25 | ) 26 | ) 27 | ) 28 | }) 29 | 30 | #### Modal #### 31 | observeEvent(input$save_button, { 32 | existing_templates <- get_template_index() 33 | 34 | showModal( 35 | modalDialog( 36 | tags$form( 37 | tags$fieldset( 38 | tags$legend("Save Template"), 39 | textInput(ns("title"), "Title", width = "100%"), 40 | textInput(ns("author"), "Author", width = "100%"), 41 | textAreaInput(ns("description"), "Description (optional)", rows = 2L, width = "100%"), 42 | tags$button( 43 | type = "button", 44 | class = "btn btn-secondary", 45 | `data-dismiss` = "modal", 46 | `data-bs-dismiss` = "modal", 47 | "Cancel" 48 | ), 49 | tags$button( 50 | id = ns("save"), 51 | type = "button", 52 | class = "btn btn-primary action-button", 53 | `data-dismiss` = "modal", 54 | `data-bs-dismiss` = "modal", 55 | "Save" 56 | ), 57 | tags$button( 58 | id = ns("save_share"), 59 | type = "button", 60 | class = "btn btn-primary action-button", 61 | `data-dismiss` = "modal", 62 | `data-bs-dismiss` = "modal", 63 | "Share", 64 | shiny::icon("share") 65 | ) 66 | ), 67 | if (nrow(existing_templates) > 0L) { 68 | tagList( 69 | tags$hr(), 70 | tags$fieldset( 71 | tags$legend("Overwrite Existing Template"), 72 | selectInput( 73 | ns("existing_template"), 74 | "Template", 75 | choices = setNames( 76 | existing_templates$id, 77 | paste(existing_templates$title, "-", existing_templates$user) 78 | ), 79 | selected = NULL, 80 | width = "100%" 81 | ), 82 | tags$button( 83 | type = "button", 84 | class = "btn btn-secondary", 85 | `data-dismiss` = "modal", 86 | `data-bs-dismiss` = "modal", 87 | "Cancel" 88 | ), 89 | tags$button( 90 | id = ns("overwrite"), 91 | type = "button", 92 | class = "btn btn-primary action-button", 93 | `data-dismiss` = "modal", 94 | `data-bs-dismiss` = "modal", 95 | "Overwrite" 96 | ), 97 | tags$button( 98 | id = ns("overwrite_share"), 99 | type = "button", 100 | class = "btn btn-primary action-button", 101 | `data-dismiss` = "modal", 102 | `data-bs-dismiss` = "modal", 103 | "Share", 104 | shiny::icon("share") 105 | ) 106 | ) 107 | ) 108 | } 109 | ), 110 | title = NULL, 111 | footer = NULL, 112 | easyClose = TRUE 113 | ) 114 | ) 115 | }) 116 | 117 | #### Saving #### 118 | saved_template_id <- reactiveVal() 119 | 120 | observe({ 121 | req(input$title, input$author) 122 | 123 | id <- save_template( 124 | html = html(), 125 | page = page(), 126 | title = input$title, 127 | desc = input$description, 128 | user = input$author 129 | ) 130 | saved_template_id(id) 131 | 132 | insertUI( 133 | selector = paste0("#", ns("select")), 134 | where = "beforeEnd", 135 | ui = createTemplateSelection( 136 | list( 137 | id = id, 138 | page = page(), 139 | user = input$author, 140 | title = input$title, 141 | description = input$description 142 | ) 143 | ) 144 | ) 145 | }) |> 146 | bindEvent( 147 | input$save, 148 | input$save_share, 149 | ignoreInit = TRUE 150 | ) 151 | 152 | observe({ 153 | shared_template_id(saved_template_id()) 154 | session$doBookmark() 155 | }) |> 156 | bindEvent( 157 | input$save_share, 158 | ignoreInit = TRUE 159 | ) 160 | 161 | #### Updating #### 162 | observe({ 163 | req(input$overwrite + input$overwrite_share > 0L) 164 | update_template( 165 | html = html(), 166 | id = input$existing_template 167 | ) 168 | }) |> 169 | bindEvent( 170 | input$overwrite, 171 | input$overwrite_share, 172 | ignoreInit = TRUE 173 | ) 174 | 175 | observe({ 176 | shared_template_id(input$existing_template) 177 | session$doBookmark() 178 | }) |> 179 | bindEvent( 180 | input$overwrite_share, 181 | ignoreInit = TRUE 182 | ) 183 | 184 | #### Deleting #### 185 | observe({ 186 | req(input$delete) 187 | showModal( 188 | modalDialog( 189 | p("Deleting this template will remove for all users. Do you wish to continue?"), 190 | title = "Warning!", 191 | footer = tagList( 192 | tags$button( 193 | type = "button", 194 | class = "btn btn-secondary", 195 | `data-dismiss` = "modal", 196 | `data-bs-dismiss` = "modal", 197 | shiny::icon("xmark"), 198 | "No" 199 | ), 200 | tags$button( 201 | id = ns("confirm_delete"), 202 | type = "button", 203 | class = "btn btn-danger action-button", 204 | `data-dismiss` = "modal", 205 | `data-bs-dismiss` = "modal", 206 | shiny::icon("check"), 207 | "Yes" 208 | ) 209 | ) 210 | ) 211 | ) 212 | }) |> 213 | bindEvent( 214 | input$select, 215 | input$delete, 216 | ignoreInit = TRUE 217 | ) 218 | 219 | observe({ 220 | delete_template(input$select) 221 | removeUI(selector = paste0(".template-option[data-value='", input$select, "']")) 222 | }) |> 223 | bindEvent( 224 | input$confirm_delete, 225 | ignoreInit = TRUE 226 | ) 227 | 228 | #### UI Updating #### 229 | selected_template <- reactive({ 230 | req(!input$delete) 231 | read_template(input$select) 232 | }) |> 233 | bindEvent( 234 | input$select, 235 | input$delete, 236 | ignoreInit = TRUE 237 | ) 238 | 239 | return(selected_template) 240 | }) 241 | } 242 | -------------------------------------------------------------------------------- /srcjs/component/Tab.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Tab extends Component { 4 | _item = 1 5 | 6 | updateComponent () {}; 7 | 8 | getPageType () { 9 | return $('#settings-page_type input:radio:checked').val() 10 | }; 11 | 12 | addPage () { 13 | const page_type = this.getPageType() 14 | 15 | const tab_name = $('#sidebar-tab-name').val() 16 | let tab_value = $('#sidebar-tab-value').val() 17 | if (tab_value === '') { 18 | tab_value = this.createID('tab') 19 | } else if (this.checkDuplicateIDs(tab_value, page_type)) { 20 | return 21 | } 22 | 23 | $('#sidebar-tab-alert div').alert('close') 24 | 25 | if (page_type === 'dashboardPage') { 26 | this.addMenuItem(tab_name, tab_value) 27 | } else { 28 | this.addTab(tab_name, tab_value) 29 | } 30 | }; 31 | 32 | addTab (tab_name, tab_value) { 33 | const nav_panel = $('ul.navbar-nav') 34 | const nav_id = nav_panel.data('tabsetid') 35 | 36 | const tab_panel = $('.tab-content') 37 | const active_class = tab_panel.html() === '' ? 'active' : '' 38 | 39 | const tab_icon = $('#sidebar-tab-icon').val() 40 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 41 | const icon_class = tab_icon === '' ? '' : $('#sidebar-tab-icon option').html().includes('fab') ? 'fab' : 'fa' 42 | const icon_html = tab_icon === '' ? '' : `` 43 | 44 | nav_panel.append(` 45 |
  • 46 | 48 | ${icon_html} 49 | ${tab_name} 50 | 51 |
  • 52 | `) 53 | 54 | tab_panel.append(` 55 |
    57 | `) 58 | 59 | this.enableSortablePage(`tab-${nav_id}-${this._item}`) 60 | this._item = this._item + 1 61 | }; 62 | 63 | addMenuItem (tab_name, tab_value) { 64 | const tab_panel = $('section.content .tab-content') 65 | const active_class = tab_panel.html() === '' ? 'active' : '' 66 | 67 | const tab_icon = $('#sidebar-tab-icon').val() 68 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 69 | const icon_class = tab_icon === '' ? '' : $('#sidebar-tab-icon option').html().includes('fab') ? 'fab' : 'fa' 70 | const icon_html = tab_icon === '' ? '' : `` 71 | 72 | $('.sidebarMenuSelectedTabItem').before(` 73 | 82 | `) 83 | 84 | if (!$('.sidebarMenuSelectedTabItem').attr('data-value')) { 85 | $('.sidebarMenuSelectedTabItem').attr('data-value', tab_value) 86 | } 87 | 88 | tab_panel.append(` 89 |
    92 | `) 93 | 94 | this.enableSortablePage(`shiny-tab-${tab_value}`) 95 | }; 96 | 97 | enableSortablePage (id) { 98 | Sortable.create(document.getElementById(id), { 99 | group: { 100 | name: 'shared', 101 | put: function (_to, _from, clone) { 102 | return !clone.classList.contains('col-sm') 103 | } 104 | } 105 | }) 106 | }; 107 | 108 | checkDuplicateNames (tab_name, page_type) { 109 | if ($(this.getNameIdentifier(tab_name, page_type)).length > 0) { 110 | $('#sidebar-tab-alert').html(` 111 | 117 | `) 118 | return true 119 | } else { 120 | return false 121 | } 122 | }; 123 | 124 | getNameIdentifier (tab_name, page_type) { 125 | if (page_type === 'dashboardPage') { 126 | return `ul.sidebar-menu a[data-name='${tab_name}']` 127 | } else { 128 | return `ul.navbar-nav a[data-name='${tab_name}']` 129 | } 130 | }; 131 | 132 | checkDuplicateIDs (tab_value, page_type) { 133 | if ($(this.getValueIdentifier(tab_value, page_type)).length > 0) { 134 | $('#sidebar-tab-alert').html(` 135 | 141 | `) 142 | return true 143 | } else { 144 | return false 145 | } 146 | }; 147 | 148 | getValueIdentifier (tab_value, page_type) { 149 | if (page_type === 'dashboardPage') { 150 | return `ul.sidebar-menu a[data-value='${tab_value}']` 151 | } else { 152 | return `ul.navbar-nav a[data-value='${tab_value}']` 153 | } 154 | }; 155 | 156 | deletePage () { 157 | const page_type = this.getPageType() 158 | const tab_name = $('#sidebar-tab-name').val() 159 | let tab_value = $('#sidebar-tab-value').val() 160 | 161 | if (this.checkMissingName(tab_name, page_type)) { 162 | return true 163 | } else if ($(this.getNameIdentifier(tab_name, page_type)).length > 1 && tab_value === '') { 164 | $('#sidebar-tab-alert').html(` 165 | 171 | `) 172 | return true 173 | } 174 | 175 | $('#sidebar-tab-alert div').alert('close') 176 | 177 | if (page_type === 'dashboardPage') { 178 | tab_value = tab_value === '' ? $(`ul.nav a[data-name='${tab_name}']`).data('value') : tab_value 179 | this.deleteMenuItem(tab_value) 180 | } else { 181 | tab_value = tab_value === '' ? $(`ul.nav a[data-name='${tab_name}']`).data('value') : tab_value 182 | this.deleteTab(tab_value) 183 | } 184 | }; 185 | 186 | deleteTab (tab_value) { 187 | $(`ul.nav a[data-value='${tab_value}']`).parent().remove() 188 | $(`.tab-content .tab-pane[data-value='${tab_value}']`).remove() 189 | }; 190 | 191 | deleteMenuItem (tab_value) { 192 | $(`#tab-${tab_value}`).parent().remove() 193 | $(`#shiny-tab-${tab_value}`).remove() 194 | }; 195 | 196 | checkMissingName (tab_name, page_type) { 197 | if ($(this.getNameIdentifier(tab_name, page_type)).length > 0) { 198 | return false 199 | } else { 200 | $('#sidebar-tab-alert').html(` 201 | 207 | `) 208 | return true 209 | } 210 | }; 211 | } 212 | -------------------------------------------------------------------------------- /srcjs/app/settings.js: -------------------------------------------------------------------------------- 1 | export function initSettings () { 2 | $('#settings-page_type').on('click', () => $('.canvas-modal').css('display', 'none')) 3 | 4 | $('.copy-ui-button').on('click', copyUICode) 5 | $('#settings-code-save').on('click', () => { $('#settings-code_button').trigger('click') }) 6 | $('#settings-code-download').on('click', () => { $('#settings-code_button').trigger('click') }) 7 | $('#settings-code-options').on('click', () => { 8 | const options_visiblity = $('#settings-code-options_fields').css('display') === 'none' ? '' : 'none' 9 | $('#settings-code-options_fields').css({ display: options_visiblity }) 10 | }) 11 | 12 | $('#css_style').on('change', applyCustomStyle) 13 | 14 | $('#remove_label').on('change', toggleComponentLabels) 15 | $('#remove_colour').on('change', toggleBackgroundColours) 16 | $('#remove_border').on('change', toggleBorders) 17 | $('#canvas_clear').on('click', showClearWarning) 18 | $('#confirm_clear').on('click', clearCanvas) 19 | 20 | $('.component-accordion .card-header button').on('click', scrollToComponent) 21 | 22 | $('body').on('click', () => { 23 | if (document.querySelector('body').classList.contains('sidebar-mini')) { 24 | document.querySelector('body').classList.remove('sidebar-mini') 25 | } 26 | }) 27 | 28 | $(document).on('click', '.clickable-dropdown', e => { e.stopPropagation() }) 29 | $('#preview').on('click', () => { 30 | $('#settings-options_button').trigger('click') 31 | $('.page-canvas-shell').addClass('preview') 32 | }) 33 | $('#canvas-close_preview').on('click', () => { $('.page-canvas-shell').removeClass('preview') }) 34 | 35 | Shiny.addCustomMessageHandler('toggleBS4DashDeps', toggleBS4DashDeps) 36 | // eslint-disable-next-line no-eval 37 | Shiny.addCustomMessageHandler('runjs', function (message) { (0, eval)(message.script) }) 38 | 39 | $('body').on('click contextmenu', closeCanvasMenu) 40 | $('#canvas-canvas').on('contextmenu', showCanvasMenu) 41 | $('#canvas-menu').on('contextmenu', e => { e.preventDefault() }) 42 | $('#sidebar-container').on('mousedown', closeCanvasMenu) 43 | 44 | $('#canvas-delete').on('click', deleteDesignerElement) 45 | 46 | $('#settings-template-search').on('input', toggleSavedTemplates) 47 | $(document).on('click', '.template-option', sendSavedTemplateID) 48 | }; 49 | 50 | function toggleComponentLabels () { 51 | if (this.checked) { 52 | $('.designer-page-template').removeClass('hidden-after-label') 53 | } else { 54 | $('.designer-page-template').addClass('hidden-after-label') 55 | } 56 | }; 57 | 58 | function toggleBackgroundColours () { 59 | if (this.checked) { 60 | $('.designer-page-template').removeClass('hidden-colour') 61 | } else { 62 | $('.designer-page-template').addClass('hidden-colour') 63 | } 64 | }; 65 | 66 | function toggleBorders () { 67 | if (this.checked) { 68 | $('.designer-page-template').removeClass('hidden-borders') 69 | } else { 70 | $('.designer-page-template').addClass('hidden-borders') 71 | } 72 | }; 73 | 74 | function showClearWarning () { 75 | if ($('#canvas-page').html() === '' || $('#canvas-page.wrapper .tab-content').html() === '') { 76 | return null 77 | } else { 78 | $('#clear_modal').modal() 79 | } 80 | }; 81 | 82 | function clearCanvas () { 83 | $('#canvas-page').html('') 84 | }; 85 | 86 | function copyUICode () { 87 | const copyText = document.getElementById('settings-code-code').textContent 88 | navigator.clipboard.writeText(copyText) 89 | $('#copy_toast').toast('show') 90 | }; 91 | 92 | function toggleBS4DashDeps (toggle) { 93 | const stylesheets = document.styleSheets 94 | for (let i = 0; i < stylesheets.length; i++) { 95 | const stylesheet = stylesheets.item(i) 96 | if (stylesheet.href && (stylesheet.href.includes('AdminLTE') || stylesheet.href.includes('bs4Dash'))) { 97 | stylesheet.disabled = toggle === 'hide' 98 | } 99 | } 100 | }; 101 | 102 | function scrollToComponent () { 103 | const cardHeader = this.closest('.card-header').id 104 | setTimeout( 105 | () => { 106 | document.getElementById(cardHeader).scrollIntoView({ behavior: 'smooth', block: 'start' }) 107 | $(this).trigger('blur') 108 | }, 109 | 250 110 | ) 111 | } 112 | 113 | let selectedElement 114 | 115 | function showCanvasMenu (event) { 116 | if ($(event.target).closest('.designer-element').length === 0) { 117 | return 118 | } 119 | event.preventDefault() 120 | 121 | const { clientX: mouseX, clientY: mouseY } = event 122 | const { normalizedX, normalizedY } = normalizeMenuPosition(mouseX, mouseY) 123 | 124 | selectedElement = $(event.target).closest('.designer-element') 125 | 126 | $('#canvas-menu').css('top', `${normalizedY}px`) 127 | $('#canvas-menu').css('left', `${normalizedX}px`) 128 | $('#canvas-menu').removeClass('visible') 129 | 130 | setTimeout(() => { $('#canvas-menu').addClass('visible') }) 131 | }; 132 | 133 | function normalizeMenuPosition (mouseX, mouseY) { 134 | const scope = document.getElementById('canvas-canvas') 135 | const contextMenu = document.getElementById('canvas-menu') 136 | 137 | let { left: scopeOffsetX, top: scopeOffsetY } = scope.getBoundingClientRect() 138 | 139 | scopeOffsetX = scopeOffsetX < 0 ? 0 : scopeOffsetX 140 | scopeOffsetY = scopeOffsetY < 0 ? 0 : scopeOffsetY 141 | 142 | const scopeX = mouseX - scopeOffsetX 143 | const scopeY = mouseY - scopeOffsetY 144 | 145 | const outOfBoundsOnX = scopeX + contextMenu.clientWidth > scope.clientWidth 146 | const outOfBoundsOnY = scopeY + contextMenu.clientHeight > scope.clientHeight 147 | 148 | let normalizedX = mouseX 149 | let normalizedY = mouseY 150 | 151 | if (outOfBoundsOnX) { 152 | normalizedX = scopeOffsetX + scope.clientWidth - contextMenu.clientWidth 153 | } 154 | if (outOfBoundsOnY) { 155 | normalizedY = scopeOffsetY + scope.clientHeight - contextMenu.clientHeight 156 | } 157 | 158 | return { normalizedX, normalizedY } 159 | }; 160 | 161 | function closeCanvasMenu () { 162 | $('#canvas-menu').removeClass('visible') 163 | }; 164 | 165 | function deleteDesignerElement (event) { 166 | selectedElement.remove() 167 | }; 168 | 169 | function applyCustomStyle (event) { 170 | const cssFile = event.target.files[0] 171 | const canvasStyle = document.getElementById('canvas-style') 172 | canvasStyle.innerHTML = '' 173 | 174 | const reader = new FileReader() 175 | reader.onload = (e) => { 176 | const file = e.target.result 177 | const lines = file.split(/\r\n|\n|(?<=\}\b)/) 178 | canvasStyle.innerHTML = lines.join('\n') 179 | 180 | const cssRules = canvasStyle.sheet.cssRules 181 | for (let i = 0; i < cssRules.length; i++) { 182 | if (cssRules[i].selectorText) { 183 | cssRules[i].selectorText = addCanvasPageSelector(cssRules[i].selectorText) 184 | } else if (cssRules[i].media && cssRules[i].cssRules) { 185 | const cssMediaRules = cssRules[i].cssRules 186 | for (let j = 0; j < cssMediaRules.length; j++) { 187 | cssMediaRules[j].selectorText = addCanvasPageSelector(cssMediaRules[j].selectorText) 188 | } 189 | } 190 | } 191 | } 192 | 193 | reader.onerror = (e) => alert(e.target.error.name) 194 | reader.readAsText(cssFile) 195 | }; 196 | 197 | function addCanvasPageSelector (selectors) { 198 | return selectors.split(/, */g).map((x) => { 199 | if (x === 'body') { 200 | return '#canvas-page' 201 | } else if (/^\.wrapper\.sidebar/.test(x)) { 202 | return x.replace('.wrapper', '') 203 | } else { 204 | return '#canvas-page ' + x 205 | } 206 | }).join(', ') 207 | }; 208 | 209 | let template_selected = false 210 | export function templateSelected () { 211 | return template_selected 212 | }; 213 | 214 | export function templateUpated () { 215 | template_selected = false 216 | } 217 | 218 | function toggleSavedTemplates (event) { 219 | const search_term = event.target.value ? event.target.value : '' 220 | 221 | document.getElementsByClassName('template-option').forEach(x => { 222 | const show_template = $(x).find('.title').html().includes(search_term) || $(x).find('.description').html().includes(search_term) 223 | x.style.display = show_template ? null : 'none' 224 | }) 225 | } 226 | 227 | function sendSavedTemplateID (event) { 228 | const selected_template = $(event.target).closest('.template-option') 229 | const page_choice = selected_template.data('page') 230 | template_selected = true 231 | 232 | const to_delete = $(event.target).closest('.delete').length > 0 || event.target.classList.contains('delete') 233 | 234 | if (!to_delete) { 235 | $('#settings-page_type').find(`input[value='${page_choice}']`).trigger('click') 236 | } 237 | 238 | document.getElementById('settings-template-search').value = null 239 | $('#settings-template-search').trigger('input') 240 | 241 | Shiny.setInputValue('settings-template-select', selected_template.data('value')) 242 | Shiny.setInputValue('settings-template-delete', to_delete) 243 | }; 244 | -------------------------------------------------------------------------------- /srcjs/component/Tabset.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component' 2 | 3 | export class Tabset extends Component { 4 | _item = 1 5 | id 6 | html 7 | is_tab = true 8 | 9 | constructor () { 10 | super() 11 | this.id = this.getTabID() 12 | 13 | if (this.isDashPage()) { 14 | this.html = ` 15 |
    16 |
    17 |
    18 | 19 |
    20 |
    21 |
    23 |
    24 |
    25 | 29 |
    ` 30 | } else { 31 | this.html = ` 32 |
    33 | 34 |
    35 |
    ` 36 | } 37 | 38 | this.updateComponent(true) 39 | }; 40 | 41 | createComponent () { 42 | if (this.isDashPage()) { 43 | const label = $('#sidebar-tabset-label').val() 44 | const title = label === '' ? '' : `
  • ${label}

  • ` 45 | const title_r = label === '' ? '' : `title = "${label}", ` 46 | 47 | const width = $('#sidebar-tabset-width_num').val() 48 | const width_class = width > 0 ? `col-sm col-sm-${width}` : '' 49 | const width_r = width > 0 ? width : 'NULL' 50 | 51 | const colour = $('#sidebar-tabset-colour').val() 52 | const colour_class = colour === 'white' ? '' : `card-outline card-${colour}` 53 | 54 | const background = $('#sidebar-tabset-background').val() 55 | const background_class = background === 'white' ? '' : `bg-${background}` 56 | 57 | return this.replaceHTMLPlaceholders(this.html, { 58 | id: this.id, 59 | title, 60 | title_r, 61 | label, 62 | width, 63 | width_class, 64 | width_r, 65 | colour, 66 | colour_class, 67 | background, 68 | background_class 69 | }) 70 | } else { 71 | const type = $('#sidebar-tabset-type').val() 72 | 73 | return this.replaceHTMLPlaceholders(this.html, { 74 | id: this.id, 75 | type 76 | }) 77 | } 78 | }; 79 | 80 | isDashPage () { 81 | return this.getPageType() === 'dashboardPage' 82 | }; 83 | 84 | getPageType () { 85 | if (typeof (window) === 'undefined') { 86 | return 'navbarPage' 87 | } else { 88 | return $('#settings-page_type input:radio:checked').val() 89 | } 90 | }; 91 | 92 | addPage () { 93 | const tab_name = $('#sidebar-tabset-name').val() 94 | let tab_value = $('#sidebar-tabset-value').val() 95 | if (tab_value === '') { 96 | tab_value = tab_name 97 | } 98 | 99 | $('#sidebar-tabset-alert div').alert('close') 100 | 101 | if (this.isDashPage()) { 102 | this.addMenuItem(tab_name, tab_value) 103 | } else { 104 | this.addTab(tab_name, tab_value) 105 | } 106 | }; 107 | 108 | addTab (tab_name, tab_value) { 109 | const nav_panel = $('.component-container>.tabbable>.nav') 110 | const nav_id = nav_panel.data('tabsetid') 111 | 112 | const tab_panel = $('.component-container>.tabbable>.tab-content') 113 | const active_class = tab_panel.children().length === 0 ? 'active' : '' 114 | 115 | const tab_icon = $('#sidebar-tabset-icon').val() 116 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 117 | const icon_class = tab_icon === '' ? '' : $('#sidebar-tabset-icon option').html().includes('fab') ? 'fab' : 'fa' 118 | const icon_html = tab_icon === '' ? '' : `` 119 | 120 | nav_panel.append(` 121 |
  • 122 | 124 | ${icon_html} 125 | ${tab_name} 126 | 127 |
  • 128 | `) 129 | 130 | tab_panel.append(` 131 |
    133 | `) 134 | 135 | this.enableSortablePage(`tab-${nav_id}-${this._item}`) 136 | this._item = this._item + 1 137 | }; 138 | 139 | addMenuItem (tab_name, tab_value) { 140 | const nav_panel = $('.component-container .card-header>.nav') 141 | const nav_id = nav_panel.data('tabsetid') 142 | 143 | const tab_panel = $('.component-container .card-body>.tab-content') 144 | const active_class = tab_panel.children().length === 0 ? 'active' : '' 145 | 146 | const tab_icon = $('#sidebar-tabset-icon').val() 147 | const icon_r = tab_icon === '' ? '' : `, icon = icon("${tab_icon}")` 148 | const icon_class = tab_icon === '' ? '' : $('#sidebar-tabset-icon option').html().includes('fab') ? 'fab' : 'fa' 149 | const icon_html = tab_icon === '' ? '' : `` 150 | 151 | nav_panel.append(` 152 | 159 | `) 160 | 161 | tab_panel.append(` 162 |
    164 | `) 165 | 166 | this.enableSortablePage(`tab-${nav_id}-${this._item}`) 167 | this._item = this._item + 1 168 | }; 169 | 170 | enableSortablePage (id) { 171 | Sortable.create(document.getElementById(id), { 172 | group: { 173 | name: 'shared', 174 | put: function (_to, _from, clone) { 175 | return !clone.classList.contains('col-sm') 176 | } 177 | } 178 | }) 179 | }; 180 | 181 | checkDuplicateNames (tab_name) { 182 | if ($(this.getNameIdentifier(tab_name)).length > 0) { 183 | $('#sidebar-tabset-alert').html(` 184 | 190 | `) 191 | return true 192 | } else { 193 | return false 194 | } 195 | }; 196 | 197 | getNameIdentifier (tab_name) { 198 | return `.component-container .nav a[data-name='${tab_name}']` 199 | }; 200 | 201 | checkDuplicateIDs (tab_value) { 202 | if ($(this.getValueIdentifier(tab_value)).length > 0) { 203 | $('#sidebar-tabset-alert').html(` 204 | 210 | `) 211 | return true 212 | } else { 213 | return false 214 | } 215 | }; 216 | 217 | getValueIdentifier (tab_value) { 218 | return `.component-container .nav a[data-value='${tab_value}']` 219 | }; 220 | 221 | deletePage () { 222 | const tab_name = $('#sidebar-tabset-name').val() 223 | let tab_value = $('#sidebar-tabset-value').val() 224 | 225 | if (this.checkMissingName(tab_name)) { 226 | return true 227 | } else if ($(this.getNameIdentifier(tab_name)).length > 1 && tab_value === '') { 228 | $('#sidebar-tabset-alert').html(` 229 | 235 | `) 236 | return true 237 | } 238 | 239 | $('#sidebar-tabset-alert div').alert('close') 240 | 241 | if (this.isDashPage()) { 242 | if (tab_value === '') { 243 | tab_value = $(`.component-container .nav-item a[data-name='${tab_name}']`).attr('href') 244 | } else { 245 | tab_value = '#' + $(`.component-container .tab-pane[data-value='${tab_value}']`).attr('id') 246 | } 247 | this.deleteMenuItem(tab_value) 248 | } else { 249 | tab_value = tab_value === '' ? $(`.component-container ul.nav a[data-name='${tab_name}']`).data('value') : tab_value 250 | this.deleteTab(tab_value) 251 | } 252 | }; 253 | 254 | deleteTab (tab_value) { 255 | $(`.component-container .nav a[data-value='${tab_value}']`).parent().remove() 256 | $(`.component-container .tab-content .tab-pane[data-value='${tab_value}']`).remove() 257 | }; 258 | 259 | deleteMenuItem (tab_value) { 260 | $(`.component-container .nav-item a[href='${tab_value}']`).parent().remove() 261 | $(`${tab_value}`).remove() 262 | }; 263 | 264 | checkMissingName (tab_name) { 265 | if ($(this.getNameIdentifier(tab_name)).length > 0) { 266 | return false 267 | } else { 268 | $('#sidebar-tabset-alert').html(` 269 | 275 | `) 276 | return true 277 | } 278 | }; 279 | 280 | getTabID () { 281 | return Math.round(Math.random() * 8999 + 1000) 282 | }; 283 | } 284 | --------------------------------------------------------------------------------