├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── CI.yaml │ └── pages.yaml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin ├── create_field.mjs ├── create_theme.mjs ├── package.json ├── pnpm-lock.yaml └── templates │ ├── TemplateField.ts │ ├── carbonFormField.svelte │ └── carbonViewField.svelte ├── docs-src ├── backoffice_edit.png ├── backoffice_list.png └── backoffice_view.png ├── package.json ├── pnpm-lock.yaml ├── src ├── app.d.ts ├── app.html ├── lib │ ├── Actions.test.ts │ ├── Actions.ts │ ├── Config.ts │ ├── Crud │ │ ├── Form.test.ts │ │ ├── Form.ts │ │ ├── Operations.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── Dashboard.test.ts │ ├── Dashboard.ts │ ├── DataTable.test.ts │ ├── DataTable.ts │ ├── Fields │ │ ├── Array.ts │ │ ├── Checkbox.ts │ │ ├── Columns.ts │ │ ├── CrudEntity.ts │ │ ├── Date.ts │ │ ├── Email.ts │ │ ├── KeyValueObject.ts │ │ ├── Number.ts │ │ ├── Tabs.ts │ │ ├── Text.ts │ │ ├── Textarea.ts │ │ ├── Toggle.ts │ │ ├── Url.ts │ │ └── index.ts │ ├── Filter.ts │ ├── Layout │ │ └── Icon.svelte │ ├── Menu.ts │ ├── Notification.ts │ ├── Pagination.ts │ ├── Request.test.ts │ ├── Request.ts │ ├── StateProcessor.test.ts │ ├── StateProcessor.ts │ ├── StateProvider.test.ts │ ├── StateProvider.ts │ ├── TestOptions.ts │ ├── i18n.ts │ ├── index.ts │ ├── themes │ │ └── svelte │ │ │ ├── carbon │ │ │ ├── Columns │ │ │ │ ├── Columns.svelte │ │ │ │ └── Columns.test.ts │ │ │ ├── Crud │ │ │ │ ├── CrudDelete.svelte │ │ │ │ ├── CrudEdit.svelte │ │ │ │ ├── CrudForm.svelte │ │ │ │ ├── CrudFormField.svelte │ │ │ │ ├── CrudList.svelte │ │ │ │ ├── CrudNew.svelte │ │ │ │ ├── CrudView.svelte │ │ │ │ └── CrudViewField.svelte │ │ │ ├── Dashboard │ │ │ │ └── Dashboard.svelte │ │ │ ├── DataTable │ │ │ │ ├── DataTable.svelte │ │ │ │ ├── Toolbar │ │ │ │ │ ├── DataTableToolbar.svelte │ │ │ │ │ ├── ToolbarAction.svelte │ │ │ │ │ └── ToolbarFilter.svelte │ │ │ │ └── actions │ │ │ │ │ ├── ItemActions.svelte │ │ │ │ │ └── SingleAction.svelte │ │ │ ├── FilterComponents │ │ │ │ ├── BooleanFilter.svelte │ │ │ │ ├── DateRangeFilter.svelte │ │ │ │ ├── Internal │ │ │ │ │ └── FilterContainer.svelte │ │ │ │ ├── NumericFilter.svelte │ │ │ │ └── TextFilter.svelte │ │ │ ├── FormFieldsComponents │ │ │ │ ├── ArrayField.svelte │ │ │ │ ├── CheckboxField.svelte │ │ │ │ ├── ColumnsField.svelte │ │ │ │ ├── CrudEntityField.svelte │ │ │ │ ├── DateField.svelte │ │ │ │ ├── DefaultField.svelte │ │ │ │ ├── DefaultField.test.ts │ │ │ │ ├── EmailField.svelte │ │ │ │ ├── EmailField.test.ts │ │ │ │ ├── KeyValueObjectField.svelte │ │ │ │ ├── NumberField.svelte │ │ │ │ ├── TabsField.svelte │ │ │ │ ├── TextField.svelte │ │ │ │ ├── TextareaField.svelte │ │ │ │ ├── ToggleField.svelte │ │ │ │ └── UrlField.svelte │ │ │ ├── Layout │ │ │ │ └── AdminLayout.svelte │ │ │ ├── Menu │ │ │ │ ├── SideMenu.svelte │ │ │ │ ├── TopLeftMenu.svelte │ │ │ │ ├── TopMenu.svelte │ │ │ │ └── TopRightMenu.svelte │ │ │ ├── Tabs │ │ │ │ └── Tabs.svelte │ │ │ ├── ViewFieldsComponents │ │ │ │ ├── ArrayField.svelte │ │ │ │ ├── ArrayField.test.ts │ │ │ │ ├── CheckboxField.svelte │ │ │ │ ├── CheckboxField.test.ts │ │ │ │ ├── ColumnsField.svelte │ │ │ │ ├── CrudEntityField.svelte │ │ │ │ ├── DateField.svelte │ │ │ │ ├── DateField.test.ts │ │ │ │ ├── DefaultField.svelte │ │ │ │ ├── DefaultField.test.ts │ │ │ │ ├── EmailField.svelte │ │ │ │ ├── EmailField.test.ts │ │ │ │ ├── KeyValueObjectField.svelte │ │ │ │ ├── KeyValueObjectField.test.ts │ │ │ │ ├── NumberField.svelte │ │ │ │ ├── NumberField.test.ts │ │ │ │ ├── TabsField.svelte │ │ │ │ ├── ToggleField.svelte │ │ │ │ ├── UrlField.svelte │ │ │ │ └── ViewLabel.svelte │ │ │ ├── index.ts │ │ │ └── lib │ │ │ │ └── ThemeChangeMenu.ts │ │ │ └── index.ts │ ├── translations │ │ ├── en.ts │ │ └── fr.ts │ └── types.ts ├── routes │ ├── +layout.ts │ ├── +page.svelte │ ├── admin │ │ └── [crud] │ │ │ └── [operation] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── docs │ │ └── +page.server.ts └── testApp │ ├── AuthorCrud.ts │ ├── BookCrud.ts │ ├── Dashboard.ts │ ├── TestCrud.ts │ ├── internal │ ├── authorsInternal.ts │ ├── booksInternal.ts │ ├── memoryStorage.ts │ └── testsInternal.ts │ └── translations │ └── fr.ts ├── static ├── .nojekyll └── favicon.png ├── svelte.config.js ├── test_cli.js ├── tsconfig.json ├── typedoc.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orbitale/SvelteAdmin/9b475d646fa40493a74e02da69f69b5ca589ba21/.eslintignore -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ], 30 | rules: { 31 | '@typescript-eslint/ban-ts-comment': 'off' 32 | }, 33 | ignorePatterns: [ 34 | '.DS_Store', 35 | 'node_modules', 36 | '/build', 37 | '/.svelte-kit', 38 | '/package', 39 | '.env', 40 | '.env.*', 41 | '!.env.example', 42 | 'pnpm-lock.yaml', 43 | 'package-lock.json', 44 | 'yarn.lock' 45 | ] 46 | }; 47 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: 'ci' 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | ci-tests: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: 21 | - 18.x 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - uses: pnpm/action-setup@v4 27 | 28 | - name: 🟢 Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'pnpm' 33 | 34 | - run: pnpm install --frozen-lockfile 35 | 36 | - name: 🖌 Lint 37 | run: pnpm run lint 38 | 39 | - name: 🛠 Unit tests 40 | run: pnpm run test:unit --run 41 | -------------------------------------------------------------------------------- /.github/workflows/pages.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Github Pages 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: 'pages' 12 | cancel-in-progress: false 13 | 14 | jobs: 15 | deploy: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: 21 | - 18.x 22 | 23 | permissions: 24 | contents: write 25 | pages: write 26 | id-token: write 27 | 28 | environment: 29 | name: github-pages 30 | url: ${{ steps.deployment.outputs.page_url }} 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | 35 | - uses: pnpm/action-setup@v4 36 | 37 | - name: 🟢 Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | cache: 'pnpm' 42 | 43 | - name: 🧰 Install dependencies 44 | run: pnpm install --frozen-lockfile 45 | 46 | - name: 🔨 Build demo app 47 | run: pnpm run build 48 | 49 | - name: 📄 Build types documentation 50 | run: pnpm run typedoc 51 | 52 | - name: 🌐 Configure Github Pages domain 53 | run: echo "svelte-admin-demo.orbitale.io" > build/CNAME 54 | 55 | - name: 🚀 Deploy 56 | uses: peaceiris/actions-gh-pages@v3 57 | with: 58 | github_token: ${{ secrets.GITHUB_TOKEN }} 59 | publish_dir: ./build 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | coverage/ 13 | 14 | /web-components 15 | 16 | # For when running "npm pack" 17 | orbitale-svelte-admin-*.tgz 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .env.* 4 | .eslintignore 5 | .eslintrc.cjs 6 | .github 7 | .gitignore 8 | .idea 9 | .npmrc 10 | .prettierignore 11 | .prettierrc 12 | .svelte-kit 13 | bin 14 | build 15 | coverage 16 | dist 17 | docs-src 18 | node_modules 19 | orbitale-svelte-admin-*.tgz 20 | package 21 | static 22 | svelte.config.js 23 | typedoc.json 24 | vite.config.js.timestamp-* 25 | vite.config.ts 26 | vite.config.ts.timestamp-* 27 | vite.lib.config.ts 28 | vite.webcomponents.config.ts 29 | yarn-error.log 30 | yarn.lock 31 | dist/ 32 | src/testApp/* 33 | src/app.html 34 | src/app.d.ts 35 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /bin/create_theme.mjs: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import prompts from 'prompts'; 4 | import { spawn } from 'node:child_process'; 5 | import fs from 'node:fs/promises'; 6 | import path from 'node:path'; 7 | import { fileURLToPath } from 'node:url'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | const projectDir = path.resolve(__dirname + '/../'); 13 | const themesDir = path.resolve(projectDir + '/src/lib/themes'); 14 | const carbonDir = path.resolve(themesDir + '/svelte/carbon'); 15 | 16 | // 17 | // main 18 | // 19 | (async () => { 20 | let themeType = ( 21 | (process.argv[2] || '').trim() || (await ask('Theme type? (svelte, react, vue)')) 22 | ).toLowerCase(); 23 | let themeName = ( 24 | (process.argv[3] || '').trim() || 25 | (await ask('What is the name of your new theme? (only lowercase letters and underscores)')) 26 | ).toLowerCase(); 27 | 28 | if (!themeName) { 29 | throw new Error('No theme name provided.'); 30 | } 31 | 32 | if (!themeType) { 33 | throw new Error('No theme type provided. Allowed: svelte, react, vue'); 34 | } 35 | if (!themeType.match(/^svelte|react|vue$/gi)) { 36 | throw new Error('Wrong template type provided. Allowed: svelte, react, vue'); 37 | } 38 | 39 | const newThemePath = carbonDir 40 | .replace(/\\/g, '/') 41 | .replace(/\/svelte\/carbon$/g, `/${themeType}/${themeName}`) 42 | .replace(/\//g, path.sep); 43 | 44 | const files = await getAllFiles(carbonDir); 45 | 46 | for (const file of files) { 47 | if (!file.match(/\.svelte$/gi)) { 48 | continue; 49 | } 50 | const newPath = path.resolve(file.replace(carbonDir, newThemePath)); 51 | const basename = newPath 52 | .replace(projectDir + path.sep, '') 53 | .replace(new RegExp('\\\\', 'g'), '/'); 54 | const dir = path.dirname(newPath); 55 | await fs.mkdir(dir, { recursive: true }); 56 | await fs.writeFile( 57 | newPath, 58 | `TODO: Implement template "${basename}" for "${themeType}/${themeName}" theme.\n` 59 | ); 60 | } 61 | 62 | await fs.copyFile(carbonDir + '/index.ts', newThemePath + '/index.ts'); 63 | 64 | const themesIndex = themesDir + '/' + themeType + '/index.ts'; 65 | let indexContent = (await fs.readFile(themesIndex)).toString(); 66 | if (!indexContent.match(new RegExp(`export *\\{ *default as ${themeName}`), 'gi')) { 67 | indexContent += `\nexport { default as ${themeName} } from './${themeName}';`; 68 | } 69 | indexContent = indexContent.replace(/\n\n+/, '\n').trim() + '\n'; 70 | await fs.writeFile(themesIndex, indexContent); 71 | })(); 72 | 73 | async function ask(question) { 74 | let value = ''; 75 | 76 | const max = 3; 77 | let i = 0; 78 | while (!value || !value.trim()) { 79 | if (i >= max) { 80 | process.stderr.write(' [ERROR] No answer. Stopping.\n'); 81 | process.exit(1); 82 | } 83 | 84 | const answer = await prompts({ 85 | type: 'text', 86 | name: 'answer', 87 | message: question 88 | }); 89 | 90 | value = (answer.answer || '').trim(); 91 | 92 | i++; 93 | } 94 | 95 | return value; 96 | } 97 | 98 | async function getAllFiles(dirPath, arrayOfFiles = []) { 99 | const files = await fs.readdir(dirPath); 100 | 101 | arrayOfFiles = arrayOfFiles || []; 102 | 103 | for (const file of files) { 104 | const stat = await fs.stat(dirPath + path.sep + file); 105 | if (stat.isDirectory()) { 106 | arrayOfFiles = await getAllFiles(dirPath + path.sep + file, arrayOfFiles); 107 | } else { 108 | arrayOfFiles.push(path.join(dirPath, path.sep, file)); 109 | } 110 | } 111 | 112 | return arrayOfFiles; 113 | } 114 | -------------------------------------------------------------------------------- /bin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-admin-bin", 3 | "version": "0.1.0", 4 | "main": "create_field.cjs", 5 | "license": "proprietary", 6 | "scripts": { 7 | "create": "node create_field.mjs" 8 | }, 9 | "devDependencies": { 10 | "chalk": "^5.3.0", 11 | "prompts": "^2.4.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bin/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | chalk: 12 | specifier: ^5.3.0 13 | version: 5.3.0 14 | prompts: 15 | specifier: ^2.4.2 16 | version: 2.4.2 17 | 18 | packages: 19 | 20 | chalk@5.3.0: 21 | resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} 22 | engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} 23 | 24 | kleur@3.0.3: 25 | resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} 26 | engines: {node: '>=6'} 27 | 28 | prompts@2.4.2: 29 | resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} 30 | engines: {node: '>= 6'} 31 | 32 | sisteransi@1.0.5: 33 | resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} 34 | 35 | snapshots: 36 | 37 | chalk@5.3.0: {} 38 | 39 | kleur@3.0.3: {} 40 | 41 | prompts@2.4.2: 42 | dependencies: 43 | kleur: 3.0.3 44 | sisteransi: 1.0.5 45 | 46 | sisteransi@1.0.5: {} 47 | -------------------------------------------------------------------------------- /bin/templates/TemplateField.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type __fullPascalCase__Options = InputFieldOptions & { 6 | // __fullPascalCase__ specific options 7 | }; 8 | 9 | /** */ 10 | export class __fullPascalCase__ extends BaseField<__fullPascalCase__Options> { 11 | readonly formComponent: FormFieldTheme = '__baseSnakeCase__'; 12 | readonly viewComponent: ViewFieldTheme = '__baseSnakeCase__'; 13 | } 14 | -------------------------------------------------------------------------------- /bin/templates/carbonFormField.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /bin/templates/carbonViewField.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {value} 10 | -------------------------------------------------------------------------------- /docs-src/backoffice_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orbitale/SvelteAdmin/9b475d646fa40493a74e02da69f69b5ca589ba21/docs-src/backoffice_edit.png -------------------------------------------------------------------------------- /docs-src/backoffice_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orbitale/SvelteAdmin/9b475d646fa40493a74e02da69f69b5ca589ba21/docs-src/backoffice_list.png -------------------------------------------------------------------------------- /docs-src/backoffice_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orbitale/SvelteAdmin/9b475d646fa40493a74e02da69f69b5ca589ba21/docs-src/backoffice_view.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orbitale/svelte-admin", 3 | "version": "0.18.0", 4 | "description": "(prototype) Crud base for Svelte projects", 5 | "repository": "https://github.com/Orbitale/SvelteAdmin", 6 | "author": "Alex \"Pierstoval\" Rock ", 7 | "license": "LGPL-3.0-or-later", 8 | "scripts": { 9 | "build": "vite build && pnpm run package", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "create:field": "node bin/create_field.mjs", 13 | "create:theme": "node bin/create_theme.mjs", 14 | "dev": "vite dev", 15 | "format": "prettier --plugin=prettier-plugin-svelte --write .", 16 | "lint": "prettier --plugin=prettier-plugin-svelte . --check . && eslint -c .eslintrc.cjs src", 17 | "package": "svelte-kit sync && svelte-package && publint", 18 | "prepublishOnly": "pnpm run package", 19 | "prettier": "prettier", 20 | "preview": "vite preview", 21 | "svelte-package": "svelte-package", 22 | "test:unit": "vitest", 23 | "typedoc": "typedoc --options typedoc.json --readme none" 24 | }, 25 | "exports": { 26 | ".": "./dist/index.js", 27 | "./themes/svelte": "./dist/themes/svelte/index.js" 28 | }, 29 | "main": "./dist/index.js", 30 | "types": "./dist/index.d.ts", 31 | "directories": { 32 | ".": "./dist/", 33 | "/themes/svelte/": "./dist/themes/svelte" 34 | }, 35 | "files": [ 36 | "./dist/*", 37 | "./src/lib/*" 38 | ], 39 | "packageManager": "pnpm@9.6.0", 40 | "peerDependencies": { 41 | "carbon-components-svelte": "^0.80.0", 42 | "carbon-icons-svelte": "^12.0.0" 43 | }, 44 | "dependencies": { 45 | "@zerodevx/svelte-toast": "^0.9.5", 46 | "carbon-components-svelte": "^0.85.2", 47 | "carbon-icons-svelte": "^12.11.0", 48 | "luxon": "^3.5.0", 49 | "svelte": "^4.2.19", 50 | "svelte-i18n": "^4.0.0", 51 | "typedoc": "^0.26.7" 52 | }, 53 | "devDependencies": { 54 | "@faker-js/faker": "^8.4.1", 55 | "@sveltejs/adapter-auto": "^3.2.4", 56 | "@sveltejs/adapter-static": "^3.0.4", 57 | "@sveltejs/kit": "^2.5.26", 58 | "@sveltejs/package": "^2.3.4", 59 | "@sveltejs/vite-plugin-svelte": "^3.1.2", 60 | "@testing-library/jest-dom": "^6.5.0", 61 | "@testing-library/svelte": "^5.2.1", 62 | "@types/node": "^22.5.4", 63 | "@types/uuid": "^10.0.0", 64 | "@typescript-eslint/eslint-plugin": "^8.5.0", 65 | "@typescript-eslint/parser": "^8.5.0", 66 | "@vitest/coverage-v8": "^2.1.0", 67 | "axios": "^1.7.7", 68 | "eslint": "^8.57.0", 69 | "eslint-config-prettier": "^8.10.0", 70 | "eslint-plugin-svelte": "^2.43.0", 71 | "intl-messageformat": "^10.5.14", 72 | "jsdom": "^24.1.3", 73 | "prettier": "^3.3.3", 74 | "prettier-plugin-svelte": "^3.2.6", 75 | "publint": "^0.2.10", 76 | "sass": "^1.78.0", 77 | "svelte-check": "^3.8.6", 78 | "tslib": "^2.7.0", 79 | "typedoc-plugin-mdn-links": "^3.2.12", 80 | "typescript": "^5.6.2", 81 | "vite": "^5.4.4", 82 | "vitest": "^2.1.0" 83 | }, 84 | "type": "module" 85 | } 86 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/Actions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { CallbackAction, UrlAction } from '$lib/Actions'; 3 | import { testOptions } from '$lib/TestOptions'; 4 | import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'; 5 | 6 | describe( 7 | 'URL actions', 8 | () => { 9 | it('handles item fields in specified request parameters', () => { 10 | const action = new UrlAction('', '/test/:field1/:field2'); 11 | const item = { 12 | field1: 'val1', 13 | field2: 'val2', 14 | field3: 'val3' 15 | }; 16 | 17 | expect(action.url(item)).toBe('/test/val1/val2'); 18 | }); 19 | 20 | it('handles item ID with default "id" when not specified', () => { 21 | const action = new UrlAction('', '/test/:field1'); 22 | const item = { 23 | id: 'identifier', 24 | field1: 'val1' 25 | }; 26 | 27 | expect(action.url(item)).toBe('/test/val1?id=identifier'); 28 | }); 29 | 30 | it('handles item ID with custom "id" property', () => { 31 | const action = new UrlAction('', '/test/:field1'); 32 | const item = { 33 | customId: 'custom_identifier', 34 | field1: 'val1' 35 | }; 36 | 37 | expect(action.url(item, 'customId')).toBe('/test/val1?id=custom_identifier'); 38 | }); 39 | 40 | it('can contain main options', () => { 41 | const icon = TrashCan; 42 | const action = new UrlAction('Some label', '/', icon, { 43 | htmlAttributes: { class: 'some-class' }, 44 | buttonKind: 'some-kind' 45 | }); 46 | 47 | expect(action.icon).toBe(icon); 48 | expect(action.options).toStrictEqual({ 49 | htmlAttributes: { class: 'some-class' }, 50 | buttonKind: 'some-kind' 51 | }); 52 | }); 53 | }, 54 | testOptions 55 | ); 56 | 57 | describe( 58 | 'Callback actions', 59 | () => { 60 | it('calls function', () => { 61 | let called = false; 62 | const callback = () => { 63 | called = true; 64 | return called; 65 | }; 66 | const action = new CallbackAction('', null, callback); 67 | 68 | expect(action.call()).toBe(true); 69 | expect(called).toBe(true); 70 | }); 71 | 72 | it('calls function with item as argument', () => { 73 | let called = false; 74 | const baseItem = { 75 | field: 'value' 76 | }; 77 | const callback = (item?: unknown): void => { 78 | called = true; 79 | // @ts-ignore 80 | item.field = 'newValue'; 81 | }; 82 | const action = new CallbackAction('', null, callback); 83 | 84 | expect(called).toBe(false); 85 | 86 | action.call(baseItem); 87 | 88 | expect(called).toBe(true); 89 | expect(baseItem.field).toBe('newValue'); 90 | }); 91 | }, 92 | testOptions 93 | ); 94 | -------------------------------------------------------------------------------- /src/lib/Actions.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType, SvelteComponent } from 'svelte'; 2 | import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; 3 | 4 | type Optional = T | null | undefined; 5 | 6 | /** */ 7 | export type ActionIcon = string | SvelteComponent | ComponentType; 8 | 9 | /** */ 10 | export type ActionOptions = { 11 | buttonKind?: string; 12 | htmlAttributes?: HTMLAnchorAttributes | HTMLButtonAttributes; 13 | [key: string]: unknown; 14 | }; 15 | 16 | /** 17 | * @interface 18 | **/ 19 | export interface Action { 20 | get label(): string; 21 | get icon(): ActionIcon | null | undefined; 22 | get options(): ActionOptions; 23 | } 24 | 25 | /** */ 26 | export abstract class DefaultAction implements Action { 27 | protected readonly _label: string; 28 | protected readonly _icon?: Optional; 29 | protected readonly _options: ActionOptions; 30 | 31 | protected constructor(label: string, icon?: Optional, options?: ActionOptions) { 32 | this._label = label; 33 | this._icon = icon; 34 | this._options = options || {}; 35 | } 36 | 37 | get label(): string { 38 | return this._label; 39 | } 40 | 41 | get icon(): ActionIcon | null | undefined { 42 | return this._icon; 43 | } 44 | 45 | get options(): ActionOptions { 46 | return this._options; 47 | } 48 | } 49 | 50 | /** */ 51 | export class CallbackAction extends DefaultAction { 52 | private readonly _callback: (item?: unknown) => void; 53 | 54 | constructor( 55 | label: string, 56 | icon: Optional, 57 | callback: (item?: unknown) => void, 58 | options?: ActionOptions 59 | ) { 60 | super(label, icon, options); 61 | this._callback = callback; 62 | } 63 | 64 | public call(item?: unknown): unknown { 65 | return this._callback.call(null, item); 66 | } 67 | } 68 | 69 | /** */ 70 | export class UrlAction extends DefaultAction { 71 | private readonly _url: string; 72 | 73 | constructor(label: string, url: string, icon?: ActionIcon, options?: ActionOptions) { 74 | super(label, icon, options); 75 | this._url = url; 76 | } 77 | 78 | public url( 79 | item: object & { [key: string]: string | number | boolean } = {}, 80 | identifierFieldName: string = 'id' 81 | ): string { 82 | if (Array.isArray(item)) { 83 | console.warn( 84 | 'Provided item for UrlAction is an array, and arrays are not supported. Using the first item of the array, or an empty object if not set.' 85 | ); 86 | item = item[0] ?? {}; 87 | } 88 | 89 | let url = this._url || ''; 90 | 91 | const mightNeedId = item[identifierFieldName] !== undefined; 92 | const hasIdAsParameter = url.match(':id'); 93 | 94 | for (const field in item) { 95 | let value = item[field]; 96 | value = !value.toString ? '' : value.toString(); 97 | if (value.length) { 98 | url = url.replace(`:${field}`, value.toString() || ''); 99 | } 100 | } 101 | 102 | if (mightNeedId && !hasIdAsParameter) { 103 | url += '?id=' + (item[identifierFieldName] ?? ''); 104 | } 105 | 106 | return `${url}`; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/Config.ts: -------------------------------------------------------------------------------- 1 | /** */ 2 | export type AdminConfig = { 3 | defaultLocale: string; 4 | autoCloseSideMenu: boolean; 5 | rootUrl: string; 6 | head: { 7 | brandName: string; 8 | appName: string; 9 | }; 10 | }; 11 | 12 | export function defaultAdminConfig(): AdminConfig { 13 | return { 14 | defaultLocale: 'en', 15 | autoCloseSideMenu: false, 16 | rootUrl: '/', 17 | head: { 18 | appName: '', 19 | brandName: '' 20 | } 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/Crud/Form.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { testOptions } from '$lib/TestOptions'; 3 | import { getSubmittedFormData } from '$lib'; 4 | 5 | describe('Submitted form data', () => { 6 | it( 7 | 'does not work if no target is specified', 8 | () => { 9 | const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); 10 | 11 | const submitted = getSubmittedFormData({} as unknown as SubmitEvent); 12 | 13 | expect(submitted).toStrictEqual({}); 14 | expect(consoleError).toHaveBeenCalledOnce(); 15 | expect(consoleError).toHaveBeenLastCalledWith( 16 | 'No form target specified. Did you forget to inject the proper SubmitEvent to the function?' 17 | ); 18 | }, 19 | testOptions 20 | ); 21 | 22 | it( 23 | 'can handle empty input', 24 | () => { 25 | const submitted = getSubmittedFormData(mockSubmitEvent()); 26 | 27 | expect(submitted).toStrictEqual({}); 28 | }, 29 | testOptions 30 | ); 31 | 32 | it( 33 | 'can handle simple input', 34 | () => { 35 | const submitted = getSubmittedFormData( 36 | mockSubmitEvent([ 37 | ['title', 'Some title'], 38 | ['description', 'Some description'] 39 | ]) 40 | ); 41 | 42 | expect(submitted).toStrictEqual({ 43 | title: 'Some title', 44 | description: 'Some description' 45 | }); 46 | }, 47 | testOptions 48 | ); 49 | 50 | it( 51 | 'can handle input containing the same key more than once', 52 | () => { 53 | const submitted = getSubmittedFormData( 54 | mockSubmitEvent([ 55 | ['title', 'First title'], 56 | ['title', 'Second title'] 57 | ]) 58 | ); 59 | 60 | expect(submitted).toStrictEqual({ 61 | title: ['First title', 'Second title'] 62 | }); 63 | }, 64 | testOptions 65 | ); 66 | }); 67 | 68 | function mockSubmitEvent(submittedData: Array<[string, string]> = []) { 69 | const form = document.createElement('form'); 70 | 71 | const submitter = document.createElement('button'); 72 | submitter.type = 'submit'; 73 | form.appendChild(submitter); 74 | 75 | submittedData.forEach(([key, value]) => { 76 | const input = document.createElement('input'); 77 | input.name = key; 78 | input.value = value; 79 | form.appendChild(input); 80 | }); 81 | 82 | return { 83 | submitter: submitter, 84 | target: form 85 | } as unknown as SubmitEvent; 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/Crud/Form.ts: -------------------------------------------------------------------------------- 1 | import type { CrudOperation, FieldInterface, FieldOptions } from '$lib'; 2 | 3 | export type SubmittedData = Record>; 4 | 5 | /** 6 | * Function to get an record of {@link FormDataEntryValue} items from an "onSubmit" form {@link SubmitEvent} object. 7 | */ 8 | export function getSubmittedFormData(event: SubmitEvent): SubmittedData { 9 | const normalizedData: SubmittedData = {}; 10 | 11 | const target = event.target; 12 | 13 | if (!target) { 14 | console.error( 15 | 'No form target specified. Did you forget to inject the proper SubmitEvent to the function?' 16 | ); 17 | 18 | return {}; 19 | } 20 | 21 | const formData = new FormData(target as HTMLFormElement, event.submitter); 22 | 23 | formData.forEach((value, key) => { 24 | if (normalizedData[key] && !Array.isArray(normalizedData[key])) { 25 | normalizedData[key] = [normalizedData[key]]; 26 | normalizedData[key].push(value); 27 | } else { 28 | normalizedData[key] = value; 29 | } 30 | }); 31 | 32 | return normalizedData; 33 | } 34 | 35 | /** 36 | * Takes the submitted data and rebuilds a valid object with it. 37 | * 38 | * For now, only cares about the case where a field is disabled and HTML submitted data are empty. 39 | * Instead of disabled fields being empty, we set them back to "defaultData", 40 | * to avoid users wrongly update data with an "undefined" value (which could corrupt a database...). 41 | * 42 | * Another good side-effect for the "disabled" part is that if a user maliciously 43 | * removes the 'disabled="disabled"' HTML attribute from an "" tag and/or updates the value, 44 | * then instead of using potentially mischievous input data, we enforce them to be consistent based on the defaults. 45 | * Kinda makes "hacking" a bit harder. 46 | */ 47 | export function sanitizeFormData( 48 | data: SubmittedData, 49 | defaultData: Record, 50 | operation: CrudOperation 51 | ): SubmittedData { 52 | operation.fields.forEach((field: FieldInterface) => { 53 | if (field.options.disabled) { 54 | if (typeof defaultData[field.name] !== 'undefined') { 55 | data[field.name] = defaultData[field.name] as FormDataEntryValue; 56 | } else { 57 | delete data[field.name]; 58 | } 59 | } 60 | }); 61 | 62 | return data; 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/Crud/Operations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Operations are a class system that allows you to determine what grids or forms you will display in your {@link Dashboard.DashboardDefinition | Dashboard} 3 | * @module 4 | */ 5 | 6 | import { 7 | type FieldOptions, 8 | type FieldInterface, 9 | type CrudDefinition, 10 | type DashboardDefinition, 11 | type Action, 12 | type CrudTheme, 13 | type FilterInterface, 14 | type FilterOptions, 15 | type PaginationOptions, 16 | defaultPaginationOptions 17 | } from '$lib'; 18 | 19 | /** */ 20 | export type CrudOperationName = 21 | | 'new' 22 | | 'edit' 23 | | 'view' 24 | | 'list' 25 | | 'delete' 26 | | 'entity_view' 27 | | 'entity_list' 28 | | string; 29 | 30 | /** */ 31 | export interface CrudOperation { 32 | /** */ readonly name: CrudOperationName; 33 | /** */ readonly label: string; 34 | /** */ readonly displayComponentName: CrudTheme; 35 | /** */ readonly fields: Array>; 36 | /** */ readonly contextActions: Array; 37 | /** */ readonly options: Record; 38 | 39 | /** */ 40 | get dashboard(): DashboardDefinition; 41 | 42 | set dashboard(dashboard: DashboardDefinition); 43 | 44 | /** */ 45 | get crud(): CrudDefinition; 46 | 47 | set crud(crud: CrudDefinition); 48 | } 49 | 50 | /** 51 | * @abstract 52 | * 53 | * @remark 54 | * This class allows you to create your own classes and extend the base operation 55 | * in case you need something else than the built-in ones. 56 | * 57 | * @example 58 | * export class PreviewOperation extends BaseCrudOperation { 59 | * // Your custom code 60 | * constructor( 61 | * fields: Array>, 62 | * actions: Array = [], 63 | * options: FormOperationOptions = DEFAULT_FORM_OPERATION_OPTION 64 | * ) { 65 | * super('preview', 'crud.preview.label', 'preview', fields, actions, options); 66 | * } 67 | * } 68 | **/ 69 | export abstract class BaseCrudOperation implements CrudOperation { 70 | private _dashboard: DashboardDefinition | null = null; 71 | private _crud: CrudDefinition | null = null; 72 | 73 | protected constructor( 74 | /** */ public readonly name: CrudOperationName, 75 | /** */ public readonly label: string, 76 | /** */ public readonly displayComponentName: CrudTheme, 77 | /** */ public readonly fields: Array>, 78 | /** */ public readonly contextActions: Array, 79 | /** */ public readonly options: Record = {} 80 | ) {} 81 | 82 | /** */ 83 | get dashboard(): DashboardDefinition { 84 | if (!this._dashboard) { 85 | throw new Error('Dashboard is not set in operation: did you try to bypass Crud setup?'); 86 | } 87 | return this._dashboard; 88 | } 89 | 90 | set dashboard(dashboard: DashboardDefinition) { 91 | if (this._dashboard === dashboard) { 92 | return; 93 | } 94 | if (this._dashboard) { 95 | console.error( 96 | 'Dashboard was set twice in an operation. If you are using HMR in development, you can ignore this issue.' 97 | ); 98 | } 99 | this._dashboard = dashboard; 100 | } 101 | 102 | /** */ 103 | get crud(): CrudDefinition { 104 | if (!this._crud) { 105 | throw new Error('Crud is not set in operation: did you try to bypass Crud setup?'); 106 | } 107 | return this._crud; 108 | } 109 | 110 | set crud(crud: CrudDefinition) { 111 | if (this._crud) { 112 | console.error( 113 | 'Crud was set twice in an operation. If you are using HMR in development, you can ignore this issue.' 114 | ); 115 | } 116 | this._crud = crud; 117 | } 118 | } 119 | 120 | /** 121 | * @see {@link New} 122 | * @see {@link Edit} 123 | **/ 124 | export type FormOperationOptions = object & { 125 | preventHttpFormSubmit: boolean; 126 | }; 127 | /** */ 128 | const DEFAULT_FORM_OPERATION_OPTION: FormOperationOptions = { 129 | preventHttpFormSubmit: true 130 | }; 131 | 132 | /** 133 | * @group Built-in operations 134 | * @category Built-in operations 135 | */ 136 | export class New extends BaseCrudOperation { 137 | /** */ 138 | constructor( 139 | fields: Array>, 140 | actions: Array = [], 141 | options: FormOperationOptions = DEFAULT_FORM_OPERATION_OPTION 142 | ) { 143 | super('new', 'crud.new.label', 'new', fields, actions, options); 144 | } 145 | } 146 | 147 | /** 148 | */ 149 | export class Edit extends BaseCrudOperation { 150 | /** */ 151 | constructor( 152 | fields: Array>, 153 | actions: Array = [], 154 | options: FormOperationOptions = DEFAULT_FORM_OPERATION_OPTION 155 | ) { 156 | super('edit', 'crud.edit.label', 'edit', fields, actions, options); 157 | } 158 | } 159 | 160 | /** 161 | * @see {@link List} 162 | **/ 163 | export type ListOperationOptions = object & { 164 | globalActions: Array; 165 | batchActions: Array; 166 | pagination: Partial; 167 | filters: FilterInterface[]; 168 | }; 169 | 170 | /** 171 | */ 172 | export class List extends BaseCrudOperation { 173 | public readonly options: ListOperationOptions; 174 | 175 | /** */ 176 | constructor( 177 | fields: Array>, 178 | itemsActions: Array = [], 179 | options: Partial = {} 180 | ) { 181 | options.globalActions ??= []; 182 | options.batchActions ??= []; 183 | options.pagination = { ...defaultPaginationOptions(), ...(options.pagination || {}) }; 184 | options.filters ??= []; 185 | super('list', 'crud.list.label', 'list', fields, itemsActions, options); 186 | this.options = options as ListOperationOptions; 187 | } 188 | } 189 | 190 | /** 191 | */ 192 | export class Delete extends BaseCrudOperation { 193 | /** */ 194 | public readonly redirectTo: Action; 195 | 196 | /** */ 197 | constructor(fields: Array>, redirectTo: Action) { 198 | super('delete', 'crud.delete.label', 'delete', fields, []); 199 | this.redirectTo = redirectTo; 200 | } 201 | } 202 | 203 | /** 204 | */ 205 | export class View extends BaseCrudOperation { 206 | /** */ 207 | constructor(fields: Array>) { 208 | super('view', 'crud.view.label', 'view', fields, []); 209 | } 210 | } 211 | 212 | /** 213 | */ 214 | export class SingleField extends BaseCrudOperation { 215 | /** */ 216 | constructor(name: CrudOperationName = 'field', options: Record = {}) { 217 | super(name, '', 'field', [], [], options); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/lib/Crud/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { CallbackStateProcessor, CallbackStateProvider, CrudDefinition, List } from '$lib'; 3 | import { testOptions } from '$lib/TestOptions'; 4 | 5 | type Book = object; 6 | 7 | describe( 8 | 'Crud definition', 9 | () => { 10 | it('can be instantiated with simple config', () => { 11 | const crud = new CrudDefinition({ 12 | name: 'books', 13 | label: { singular: '', plural: '' }, 14 | operations: [new List([])], 15 | stateProvider: new CallbackStateProvider(async () => null), 16 | stateProcessor: new CallbackStateProcessor(() => {}) 17 | }); 18 | 19 | expect(crud).toBeDefined(); 20 | }); 21 | it('can be instantiated with simple config containing existing default operation name', () => { 22 | const crud = new CrudDefinition({ 23 | name: 'books', 24 | label: { singular: '', plural: '' }, 25 | operations: [new List([])], 26 | defaultOperationName: 'list', 27 | stateProvider: new CallbackStateProvider(async () => null), 28 | stateProcessor: new CallbackStateProcessor(() => {}) 29 | }); 30 | 31 | expect(crud).toBeDefined(); 32 | }); 33 | 34 | it('cannot be instantiated without operations', () => { 35 | const construct = () => { 36 | new CrudDefinition({ 37 | name: 'books', 38 | label: { singular: '', plural: '' }, 39 | operations: [], 40 | stateProvider: new CallbackStateProvider(async () => null), 41 | stateProcessor: new CallbackStateProcessor(() => {}) 42 | }); 43 | }; 44 | 45 | expect(construct).toThrowError(/^Crud definition "books" has no Crud operations set\./g); 46 | }); 47 | 48 | it("cannot be instantiated if operations list's first element is not properly set", () => { 49 | const construct = () => { 50 | const operations = Array(2); 51 | operations[1] = new List([]); 52 | new CrudDefinition({ 53 | name: 'books', 54 | label: { singular: '', plural: '' }, 55 | operations: operations, 56 | stateProvider: new CallbackStateProvider(async () => null), 57 | stateProcessor: new CallbackStateProcessor(() => {}) 58 | }); 59 | }; 60 | 61 | expect(construct).toThrowError( 62 | /^Crud definition "books" has an invalid default operation name "undefined"\./g 63 | ); 64 | }); 65 | 66 | it('cannot be instantiated if default operation name does not exist in operations list', () => { 67 | const construct = () => { 68 | new CrudDefinition({ 69 | name: 'books', 70 | label: { singular: '', plural: '' }, 71 | operations: [new List([])], 72 | defaultOperationName: 'edit', 73 | stateProvider: new CallbackStateProvider(async () => null), 74 | stateProcessor: new CallbackStateProcessor(() => {}) 75 | }); 76 | }; 77 | 78 | expect(construct).toThrowError( 79 | /^Crud definition "books" has no default operation named "edit"\./g 80 | ); 81 | }); 82 | }, 83 | testOptions 84 | ); 85 | -------------------------------------------------------------------------------- /src/lib/Crud/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CrudOperation, 3 | type StateProvider, 4 | type StateProcessor, 5 | type DashboardDefinition 6 | } from '$lib'; 7 | 8 | /** */ 9 | export type CrudDefinitionOptionsArgument = { 10 | name: string; 11 | label: { 12 | singular: string; 13 | plural: string; 14 | }; 15 | defaultOperationName?: string; 16 | identifierFieldName?: 'id' | string; 17 | 18 | /** 19 | * Will apply a default minimum timeout (in milliseconds) when running a {@link StateProvider} or {@link StateProcessor}. 20 | * If this option is defined, and the provider or processor call's duration is below this value, it will still wait this amount of time before returning the actual provider/processor value. 21 | * 22 | * The goal of this is to avoid epilepsy-like issues if providers or processors respond too quickly, especially when dealing with List operations and filters. 23 | * This will create a "wait time" for the end user, so that the screen does not blink too much and there eyes (and brain) will not be stressed too much. 24 | */ 25 | minStateLoadingTimeMs?: number; 26 | 27 | operations: Array; 28 | stateProvider: StateProvider; 29 | stateProcessor: StateProcessor; 30 | }; 31 | 32 | export type CrudDefinitionOptions = Required>; 33 | 34 | /** 35 | * Crud definition, object used to create an abstract Crud. 36 | * 37 | * @remarks 38 | * Crud objects are related to a single Entity type, 39 | * and contain several Crud Operations, as well as the main 40 | * objects that care about persistence: state providers and processors. 41 | * 42 | * @example 43 | * type Book = {id: number, title: string, description: string}; 44 | * 45 | * const BooksCrud = new CrudDefinition({ 46 | * name: 'books', 47 | * label: {singular: 'Book', plural: 'Books'}, 48 | * operations: [], 49 | * stateProvider: ..., 50 | * stateProcessor: ..., 51 | * }); 52 | * 53 | * @typeParam EntityType - The object type that will be used by providers and processors. 54 | */ 55 | export class CrudDefinition { 56 | /** */ public readonly name: string; 57 | /** */ public readonly options: CrudDefinitionOptions; 58 | private _dashboard: DashboardDefinition | null = null; 59 | 60 | /** 61 | * @param {CrudDefinitionOptionsArgument} options 62 | **/ 63 | constructor(options: CrudDefinitionOptionsArgument) { 64 | const name = options.name; 65 | this.name = name; 66 | 67 | if (!options?.operations.length) { 68 | throw new Error( 69 | `Crud definition "${name}" has no Crud operations set.\nDid you forget to add an "operations" key when creating your Crud definition?` 70 | ); 71 | } 72 | 73 | const defaultOperationName = options.defaultOperationName || options.operations[0]?.name; 74 | if (!defaultOperationName || !defaultOperationName.length) { 75 | throw new Error( 76 | `Crud definition "${name}" has an invalid default operation name "${defaultOperationName}".\nYou can fix this issue by customizing the "defaultOperationName" option when creating your Crud definition.` 77 | ); 78 | } 79 | 80 | const defaultOperation = options.operations 81 | .filter((operation) => operation.name === defaultOperationName) 82 | .shift(); 83 | if (!defaultOperation) { 84 | throw new Error( 85 | `Crud definition "${name}" has no default operation named "${defaultOperationName}".\nAvailable operation names: ${options.operations 86 | .map((o) => o.name) 87 | .join(', ')}.` 88 | ); 89 | } 90 | options.operations.forEach((operation: CrudOperation) => (operation.crud = this)); 91 | 92 | options.defaultOperationName = defaultOperation.name; 93 | options.identifierFieldName ??= 'id'; 94 | 95 | this.options = options as CrudDefinitionOptions; 96 | } 97 | 98 | /** */ 99 | get dashboard(): DashboardDefinition { 100 | if (!this._dashboard) { 101 | throw new Error('Dashboard is not set in Crud definition: did you try to bypass Crud setup?'); 102 | } 103 | return this._dashboard; 104 | } 105 | 106 | set dashboard(dashboard: DashboardDefinition) { 107 | if (this._dashboard === dashboard) { 108 | return; 109 | } 110 | if (this._dashboard) { 111 | console.error( 112 | 'Dashboard was set twice in a Crud definition. If you are using HMR in development, you can ignore this issue.' 113 | ); 114 | } 115 | this.options.operations.forEach((operation) => { 116 | operation.dashboard = dashboard; 117 | }); 118 | this._dashboard = dashboard; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/Dashboard.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { 3 | CallbackStateProcessor, 4 | CallbackStateProvider, 5 | CrudDefinition, 6 | DashboardDefinition, 7 | List 8 | } from '$lib'; 9 | import { testOptions } from '$lib/TestOptions'; 10 | import carbon from '$lib/themes/svelte/carbon'; 11 | 12 | type Book = object; 13 | 14 | describe( 15 | 'Dashboard', 16 | () => { 17 | it('can be instantiated with simple config', () => { 18 | const dashboard = new DashboardDefinition({ 19 | theme: carbon, 20 | adminConfig: {}, 21 | cruds: [ 22 | new CrudDefinition({ 23 | name: 'books', 24 | label: { singular: 'Book', plural: 'Books' }, 25 | operations: [new List([])], 26 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)), 27 | stateProcessor: new CallbackStateProcessor(() => {}) 28 | }) 29 | ] 30 | }); 31 | 32 | expect(dashboard).toBeDefined(); 33 | }); 34 | 35 | it('has a properly defined first action', () => { 36 | const dashboard = new DashboardDefinition({ 37 | theme: carbon, 38 | adminConfig: {}, 39 | cruds: [ 40 | new CrudDefinition({ 41 | name: 'books', 42 | label: { singular: 'Book', plural: 'Books' }, 43 | operations: [new List([])], 44 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)), 45 | stateProcessor: new CallbackStateProcessor(() => {}) 46 | }) 47 | ] 48 | }); 49 | 50 | expect(dashboard).toBeDefined(); 51 | expect(dashboard.getFirstActionUrl()).toBe('/books/list'); 52 | }); 53 | 54 | it('fails when two cruds have the same name', () => { 55 | const createDashboard = () => { 56 | new DashboardDefinition({ 57 | theme: carbon, 58 | adminConfig: {}, 59 | cruds: [ 60 | new CrudDefinition({ 61 | name: 'books', 62 | label: { singular: 'Book', plural: 'Books' }, 63 | operations: [new List([])], 64 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)), 65 | stateProcessor: new CallbackStateProcessor(() => {}) 66 | }), 67 | new CrudDefinition({ 68 | name: 'books', 69 | label: { singular: 'Book', plural: 'Books' }, 70 | operations: [new List([])], 71 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)), 72 | stateProcessor: new CallbackStateProcessor(() => {}) 73 | }) 74 | ] 75 | }); 76 | }; 77 | expect(createDashboard).toThrow( 78 | 'Crud name "books" is used in at least two different Crud objects. Crud names must be unique.' 79 | ); 80 | }); 81 | }, 82 | testOptions 83 | ); 84 | -------------------------------------------------------------------------------- /src/lib/Dashboard.ts: -------------------------------------------------------------------------------- 1 | import { get, writable, type Writable } from 'svelte/store'; 2 | import { 3 | type AdminConfig, 4 | type MenuLink, 5 | type CrudDefinition, 6 | type Dictionaries, 7 | defaultAdminConfig, 8 | type ThemeConfig 9 | } from '$lib'; 10 | 11 | /** */ 12 | export type DashboardStores = { 13 | sideMenu: Writable>; 14 | topLeftMenu: Writable>; 15 | topRightMenu: Writable>; 16 | }; 17 | 18 | /** 19 | */ 20 | export type DashboardDefinitionOptions = { 21 | theme: ThemeConfig; 22 | adminConfig: Partial; 23 | cruds: Array>; 24 | rootUrl?: string; 25 | sideMenu?: Array; 26 | topLeftMenu?: Array; 27 | topRightMenu?: Array; 28 | localeDictionaries?: Dictionaries; 29 | }; 30 | 31 | /** 32 | * @example 33 | * export const dashboard = new DashboardDefinition({ 34 | * theme: carbon, // Import from the lib's themes 35 | * admin: ..., // see AdminConfig 36 | * 37 | * // The main menu on the left side of the page 38 | * sideMenu: [ 39 | * new UrlAction('Homepage', '/', Home), 40 | * new UrlAction('Book', '/admin/books/list', Book) 41 | * ], 42 | * 43 | * // Here you set all the Crud configurations of your admin panel 44 | * // For organization purposes, we recommend you to define your Crud configs 45 | * // in separate typescript files, it makes it easier to read and maintain. 46 | * cruds: [booksCrud] 47 | * }); 48 | */ 49 | export class DashboardDefinition { 50 | /** */ public readonly theme: ThemeConfig; 51 | /** */ public readonly adminConfig: AdminConfig; 52 | /** */ public readonly cruds: Array>; 53 | /** */ public readonly localeDictionaries: Dictionaries = {}; 54 | /** */ public readonly stores: DashboardStores; 55 | 56 | public readonly options = {}; 57 | 58 | /** */ 59 | constructor(options: DashboardDefinitionOptions) { 60 | this.theme = options.theme; 61 | this.adminConfig = { ...defaultAdminConfig(), ...(options.adminConfig || {}) }; 62 | this.cruds = options.cruds; 63 | this.localeDictionaries = options.localeDictionaries || {}; 64 | this.cruds.forEach((crud: CrudDefinition) => (crud.dashboard = this)); 65 | this.stores = { 66 | sideMenu: writable(options.sideMenu || []), 67 | topLeftMenu: writable(options.topLeftMenu || []), 68 | topRightMenu: writable(options.topRightMenu || []) 69 | }; 70 | this.checkUniqueCruds(); 71 | } 72 | 73 | /** @deprecated Use builtin Dashboard stores instead */ 74 | get sideMenu(): Array { 75 | return get(this.stores.sideMenu); 76 | } 77 | 78 | /** @deprecated Use builtin Dashboard stores instead */ 79 | get topLeftMenu(): Array { 80 | return get(this.stores.topLeftMenu); 81 | } 82 | 83 | /** @deprecated Use builtin Dashboard stores instead */ 84 | get topRightMenu(): Array { 85 | return get(this.stores.topRightMenu); 86 | } 87 | 88 | /** */ 89 | public getFirstActionUrl(): string { 90 | const firstCrud = this.cruds[0]; 91 | const firstOperation = firstCrud.options.operations[0]; 92 | const root = this.adminConfig.rootUrl.replace(/(^\/*)|(\/*$)/gi, '') || ''; 93 | 94 | return `${root ? '/' + root : ''}/${firstCrud.name}/${firstOperation.name}`; 95 | } 96 | 97 | private checkUniqueCruds() { 98 | const existingCruds: Array = []; 99 | this.cruds.forEach((crud: CrudDefinition) => { 100 | if (existingCruds.indexOf(crud.name) >= 0) { 101 | throw new Error( 102 | `Crud name "${crud.name}" is used in at least two different Crud objects. Crud names must be unique.` 103 | ); 104 | } 105 | existingCruds.push(crud.name); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/DataTable.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { 3 | List, 4 | CheckboxField, 5 | BaseField, 6 | NumberField, 7 | Tabs, 8 | TextareaField, 9 | TextField, 10 | ToggleField, 11 | UrlField 12 | } from '$lib'; 13 | import { Columns } from '$lib/Fields/Columns'; 14 | import { testOptions } from '$lib/TestOptions'; 15 | 16 | describe( 17 | 'DataTable', 18 | () => { 19 | it('can create a Tabs configuration with 1 level of nested fields', () => { 20 | const fields = [ 21 | new Tabs('', '', [ 22 | { name: 'tab_1', fields: [new CheckboxField('field_1')] }, 23 | { name: 'tab_2', fields: [new BaseField('field_2')] }, 24 | { name: 'tab_3', fields: [new NumberField('field_3')] }, 25 | { name: 'tab_4', fields: [new TextareaField('field_4')] }, 26 | { name: 'tab_5', fields: [new TextField('field_5')] }, 27 | { name: 'tab_6', fields: [new ToggleField('field_6')] }, 28 | { name: 'tab_7', fields: [new UrlField('field_7')] } 29 | ]) 30 | ]; 31 | const list = new List(fields); 32 | 33 | expect(list).toBeDefined(); 34 | }); 35 | 36 | it('can create a Columns configuration with 1 level of nested fields', () => { 37 | const fields = [ 38 | new Columns('', '', [ 39 | { name: 'column_1', fields: [new CheckboxField('field_1')] }, 40 | { name: 'column_2', fields: [new BaseField('field_2')] }, 41 | { name: 'column_3', fields: [new NumberField('field_3')] }, 42 | { name: 'column_4', fields: [new TextareaField('field_4')] }, 43 | { name: 'column_5', fields: [new TextField('field_5')] }, 44 | { name: 'column_6', fields: [new ToggleField('field_6')] }, 45 | { name: 'column_7', fields: [new UrlField('field_7')] } 46 | ]) 47 | ]; 48 | const list = new List(fields); 49 | 50 | expect(list).toBeDefined(); 51 | }); 52 | }, 53 | testOptions 54 | ); 55 | -------------------------------------------------------------------------------- /src/lib/DataTable.ts: -------------------------------------------------------------------------------- 1 | import type { CrudOperation } from '$lib'; 2 | 3 | export type DataTableKey = string; 4 | 5 | export type DataTableValue = unknown; 6 | 7 | export type BaseDataTableHeader = { 8 | key: DataTableKey; 9 | [key: string]: unknown; 10 | }; 11 | 12 | export type DataTableEmptyHeader = BaseDataTableHeader & { 13 | empty: boolean; 14 | }; 15 | 16 | export type DataTableNonEmptyHeader = BaseDataTableHeader & { 17 | value: DataTableValue; 18 | }; 19 | 20 | export type DataTableHeader = DataTableEmptyHeader | DataTableNonEmptyHeader; 21 | 22 | export type Header = DataTableHeader; 23 | export type Headers = Array
; 24 | 25 | export type Row = { 26 | __crud_operation: CrudOperation; 27 | id: number | string; 28 | [key: string]: DataTableValue; 29 | }; 30 | export type Rows = Array; 31 | -------------------------------------------------------------------------------- /src/lib/Fields/Array.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InputFieldOptions, 3 | FormFieldTheme, 4 | ViewFieldTheme, 5 | FieldInterface, 6 | FieldOptions 7 | } from '$lib'; 8 | import { BaseField } from '$lib/Fields'; 9 | 10 | /** */ 11 | export type ArrayFieldOptions = InputFieldOptions & { 12 | // 13 | }; 14 | 15 | /** */ 16 | export class ArrayField< 17 | InnerField extends FieldInterface 18 | > extends BaseField { 19 | readonly formComponent: FormFieldTheme = 'array'; 20 | readonly viewComponent: ViewFieldTheme = 'array'; 21 | public readonly innerField: InnerField; 22 | 23 | constructor( 24 | name: string, 25 | label: string = '', 26 | innerField: InnerField, 27 | options?: ArrayFieldOptions 28 | ) { 29 | super(name, label, options); 30 | this.innerField = innerField; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/Fields/Checkbox.ts: -------------------------------------------------------------------------------- 1 | import type { CommonFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type CheckboxOptions = CommonFieldOptions; 6 | 7 | /** */ 8 | export class CheckboxField extends BaseField { 9 | readonly formComponent: FormFieldTheme = 'checkbox'; 10 | readonly viewComponent: ViewFieldTheme = 'checkbox'; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/Fields/Columns.ts: -------------------------------------------------------------------------------- 1 | import type { FieldInterface, FieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | 3 | /** */ 4 | export type ColumnOptions = FieldOptions & { 5 | name: string; 6 | label?: string; 7 | }; 8 | 9 | /** 10 | * 11 | * @remark 12 | * The sizes and offsets are related to the grid used by your theme.
13 | * For example, **Carbon** theme has a dynamic grid up from 4 to 16 columns 14 | * depending on viewport, while **Bootstrap**-based themes have 12 columns. 15 | */ 16 | export type ColumnedFields> = Array<{ 17 | name?: string; 18 | label?: string; 19 | 20 | /** Size in proportion of the theme's grid configuration and full size */ 21 | size?: number; 22 | /** Offset in proportion of the theme's grid configuration and full size */ 23 | offset?: number; 24 | fields: Array; 25 | }>; 26 | 27 | /** */ 28 | export class Columns implements FieldInterface { 29 | public readonly formComponent: FormFieldTheme = 'column'; 30 | public readonly viewComponent: ViewFieldTheme = 'column'; 31 | 32 | constructor( 33 | public readonly name: string, 34 | public readonly label: string = '', 35 | public readonly fields: ColumnedFields = [], 36 | public readonly options: ColumnOptions = {} as ColumnOptions 37 | ) {} 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/Fields/CrudEntity.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | export type CrudEntityListProviderOptions = { [key: string]: string }; 5 | export type CrudEntityGetProviderOptions = { [key: string]: string }; 6 | 7 | /** */ 8 | export type CrudEntityOptions = InputFieldOptions & { 9 | crud_name: string; 10 | list_provider_operation?: { 11 | name?: 'entity_list' | string; 12 | options?: CrudEntityListProviderOptions; 13 | label_field: string; 14 | value_field?: 'id' | string; 15 | }; 16 | get_provider_operation: { 17 | name?: 'entity_view' | string; 18 | options?: CrudEntityGetProviderOptions; 19 | entity_field: string; 20 | }; 21 | }; 22 | 23 | /** */ 24 | export class CrudEntityField extends BaseField { 25 | readonly formComponent: FormFieldTheme = 'crud_entity'; 26 | readonly viewComponent: ViewFieldTheme = 'crud_entity'; 27 | 28 | constructor(name: string, label: string = '', options: CrudEntityOptions) { 29 | super(name, label, options); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/Fields/Date.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type DateOptions = InputFieldOptions & { 6 | formFormat?: string; // Default: 'Y-m-d' 7 | }; 8 | 9 | /** */ 10 | export class DateField extends BaseField { 11 | readonly formComponent: FormFieldTheme = 'date'; 12 | readonly viewComponent: ViewFieldTheme = 'date'; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/Fields/Email.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type EmailFieldOptions = InputFieldOptions & {}; 6 | 7 | /** */ 8 | export class EmailField extends BaseField { 9 | readonly formComponent: FormFieldTheme = 'email'; 10 | readonly viewComponent: ViewFieldTheme = 'email'; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/Fields/KeyValueObject.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type ObjectOptions = InputFieldOptions & { 6 | // maxDepth?: number, // TODO: check if we can create an input with nested key=>value pairs with a max depth 7 | }; 8 | 9 | /** */ 10 | export class KeyValueObjectField extends BaseField { 11 | readonly formComponent: FormFieldTheme = 'key_value_object'; 12 | readonly viewComponent: ViewFieldTheme = 'key_value_object'; 13 | 14 | public readonly propertyPath: string; 15 | 16 | constructor( 17 | name: string, 18 | label: string = '', 19 | propertyTree: string = '', 20 | options: ObjectOptions = {} as ObjectOptions 21 | ) { 22 | super(name, label, options); 23 | this.propertyPath = propertyTree; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/Fields/Number.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type NumberOptions = InputFieldOptions & { 6 | min?: number; 7 | max?: number; 8 | }; 9 | 10 | /** */ 11 | export class NumberField extends BaseField { 12 | readonly formComponent: FormFieldTheme = 'number'; 13 | readonly viewComponent: ViewFieldTheme = 'number'; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/Fields/Tabs.ts: -------------------------------------------------------------------------------- 1 | import type { FieldInterface, FieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | 3 | /** */ 4 | export type TabOptions = FieldOptions & { 5 | name: string; 6 | label?: string; 7 | }; 8 | 9 | /** */ 10 | export type TabbedFields> = Array<{ 11 | name: string; 12 | label?: string; 13 | fields: Array; 14 | }>; 15 | 16 | /** */ 17 | export class Tabs implements FieldInterface { 18 | public readonly formComponent: FormFieldTheme = 'tabs'; 19 | public readonly viewComponent: ViewFieldTheme = 'tabs'; 20 | 21 | constructor( 22 | public readonly name: string, 23 | public readonly label: string = '', 24 | public readonly fields: TabbedFields = [], 25 | public readonly options: TabOptions = {} as TabOptions 26 | ) {} 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/Fields/Text.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type TextOptions = InputFieldOptions & { 6 | maxLength?: number; 7 | stripTags?: boolean; 8 | }; 9 | 10 | /** */ 11 | export class TextField extends BaseField { 12 | readonly formComponent: FormFieldTheme = 'text'; 13 | readonly viewComponent: ViewFieldTheme = 'text'; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/Fields/Textarea.ts: -------------------------------------------------------------------------------- 1 | import type { TextOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type TextareaOptions = TextOptions & { 6 | rows?: number; 7 | }; 8 | 9 | /** */ 10 | export class TextareaField extends BaseField { 11 | readonly formComponent: FormFieldTheme = 'textarea'; 12 | readonly viewComponent: ViewFieldTheme = 'textarea'; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/Fields/Toggle.ts: -------------------------------------------------------------------------------- 1 | import type { CommonFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type ToggleOptions = CommonFieldOptions; 6 | 7 | /** */ 8 | export class ToggleField extends BaseField { 9 | readonly formComponent: FormFieldTheme = 'toggle'; 10 | readonly viewComponent: ViewFieldTheme = 'toggle'; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/Fields/Url.ts: -------------------------------------------------------------------------------- 1 | import type { InputFieldOptions, FormFieldTheme, ViewFieldTheme } from '$lib'; 2 | import { BaseField } from '$lib/Fields'; 3 | 4 | /** */ 5 | export type UrlOptions = InputFieldOptions & { 6 | openInNewTab?: boolean; 7 | }; 8 | 9 | /** */ 10 | export class UrlField extends BaseField { 11 | readonly formComponent: FormFieldTheme = 'url'; 12 | readonly viewComponent: ViewFieldTheme = 'url'; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/Fields/index.ts: -------------------------------------------------------------------------------- 1 | import type { FormFieldTheme, ViewFieldTheme } from '../types'; 2 | 3 | /** */ 4 | export type FieldOptions = { 5 | disableOnOperations?: Array; 6 | [key: string]: string | number | boolean | unknown; 7 | }; 8 | 9 | /** */ 10 | export type CommonFieldOptions = FieldOptions & { 11 | required?: boolean; 12 | disabled?: boolean; 13 | sortable?: true; 14 | help?: string; 15 | }; 16 | 17 | /** */ 18 | export type InputFieldOptions = CommonFieldOptions & { 19 | placeholder?: string; 20 | }; 21 | 22 | /** */ 23 | export interface FieldInterface { 24 | readonly name: string; 25 | readonly label: string; 26 | readonly options: OptionsType; 27 | readonly formComponent: FormFieldTheme; 28 | readonly viewComponent: ViewFieldTheme; 29 | } 30 | 31 | /** 32 | * @abstract 33 | **/ 34 | export class BaseField implements FieldInterface { 35 | public readonly formComponent: FormFieldTheme = 'default'; 36 | public readonly viewComponent: ViewFieldTheme = 'default'; 37 | 38 | constructor( 39 | public readonly name: string, 40 | public readonly label: string = '', 41 | public readonly options: OptionsType = {} as OptionsType 42 | ) { 43 | this.label = label || name; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/Filter.ts: -------------------------------------------------------------------------------- 1 | import type { FilterTheme } from '$lib'; 2 | 3 | /** */ 4 | export type FilterOptions = { [key: string]: string }; 5 | 6 | /** */ 7 | export interface FilterInterface { 8 | readonly field: string; 9 | readonly label: string; 10 | readonly options: T; 11 | readonly componentName: FilterTheme; 12 | } 13 | 14 | /** */ 15 | export abstract class Filter implements FilterInterface { 16 | public readonly field: string; 17 | public readonly label: string; 18 | public readonly options: T; 19 | abstract readonly componentName: FilterTheme; 20 | 21 | constructor(field: string, label?: string, options?: T) { 22 | this.field = field; 23 | this.label = label || field; 24 | this.options = (options as T) || {}; 25 | } 26 | } 27 | 28 | /** */ 29 | export class TextFilter extends Filter { 30 | public readonly componentName: FilterTheme = 'text'; 31 | } 32 | 33 | /** */ 34 | export class BooleanFilter extends Filter { 35 | public readonly componentName: FilterTheme = 'boolean'; 36 | } 37 | 38 | /** */ 39 | export class DateRangeFilter extends Filter { 40 | public readonly componentName: FilterTheme = 'date_range'; 41 | } 42 | 43 | /** */ 44 | export class ExistsFilter extends Filter { 45 | public readonly componentName: FilterTheme = 'boolean'; 46 | } 47 | 48 | /** */ 49 | export class NumericFilter extends Filter { 50 | public readonly componentName: FilterTheme = 'numeric'; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/Layout/Icon.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | {#if icon instanceof SvelteComponent || typeof icon === 'function' || typeof icon?.$$render !== 'undefined'} 19 | 20 | {:else if typeof icon === 'string'} 21 | {icon} 22 | {:else} 23 | {(icon || '').toString()} 24 | {/if} 25 | -------------------------------------------------------------------------------- /src/lib/Menu.ts: -------------------------------------------------------------------------------- 1 | import { type Action, type ActionIcon, type ActionOptions, DefaultAction } from '$lib'; 2 | 3 | type Optional = T | null | undefined; 4 | 5 | /** */ 6 | export type MenuLink = Action; 7 | 8 | /** */ 9 | export class Submenu extends DefaultAction { 10 | private readonly _links: Array; 11 | 12 | get links(): Array { 13 | return this._links; 14 | } 15 | 16 | constructor( 17 | label: string, 18 | icon: Optional, 19 | links: Array, 20 | options?: ActionOptions 21 | ) { 22 | super(label, icon, options); 23 | this._links = links; 24 | } 25 | } 26 | 27 | /** */ 28 | export class Divider extends DefaultAction { 29 | constructor() { 30 | super('divider', undefined, {}); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/Notification.ts: -------------------------------------------------------------------------------- 1 | import { toast } from '@zerodevx/svelte-toast'; 2 | 3 | /** */ 4 | export default function message(content: string, type: ToastType) { 5 | const toast = new Toast(content, type || 'info'); 6 | 7 | toast.show(); 8 | 9 | return toast; 10 | } 11 | 12 | /** */ 13 | export function success(content: string): Toast { 14 | return message(content, 'success'); 15 | } 16 | 17 | /** */ 18 | export function error(content: string): Toast { 19 | return message(content, 'error'); 20 | } 21 | 22 | /** */ 23 | export function warning(content: string): Toast { 24 | return message(content, 'warning'); 25 | } 26 | 27 | /** */ 28 | export function info(content: string): Toast { 29 | return message(content, 'info'); 30 | } 31 | 32 | /** */ 33 | export type ToastType = 'info' | 'success' | 'warning' | 'error'; 34 | 35 | /** */ 36 | export class Toast { 37 | private readonly _content: string; 38 | private readonly _toast_type: ToastType; 39 | 40 | constructor(content: string, type: ToastType) { 41 | this._content = (content || '').replace(/\n/g, '
'); 42 | this._toast_type = type; 43 | } 44 | 45 | show() { 46 | toast.push({ 47 | msg: this._content, 48 | theme: this.theme(), 49 | pausable: true 50 | }); 51 | } 52 | 53 | private theme(): { [key: string]: string } { 54 | const style = Toast.getColors(this._toast_type); 55 | 56 | style['--toastWidth'] = '25rem'; 57 | 58 | return style; 59 | } 60 | 61 | static getColors(toast_type: ToastType): { [key: string]: string } { 62 | switch (toast_type) { 63 | case 'info': 64 | return { '--toastBackground': '#6ee0f7', '--toastColor': '' }; 65 | case 'warning': 66 | return { '--toastBackground': '#f7d56e', '--toastColor': '' }; 67 | case 'error': 68 | return { '--toastBackground': '#f07582', '--toastColor': '' }; 69 | case 'success': 70 | return { '--toastBackground': '#79ecb5', '--toastColor': '' }; 71 | default: 72 | throw 'Invalid toast type.'; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/Pagination.ts: -------------------------------------------------------------------------------- 1 | /** */ 2 | export class PaginatedResults { 3 | constructor( 4 | public readonly currentPage: number, 5 | public readonly numberOfPages: number, 6 | public readonly numberOfItems: number, 7 | public readonly currentItems: Array 8 | ) {} 9 | } 10 | 11 | /** */ 12 | export type PaginationOptions = { 13 | enabled: boolean; 14 | itemsPerPage: number; 15 | }; 16 | 17 | /** */ 18 | export function defaultPaginationOptions(): PaginationOptions { 19 | return { 20 | enabled: true, 21 | itemsPerPage: 10 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/Request.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { testOptions } from '$lib/TestOptions'; 3 | import { getRequestParams } from '$lib/Request'; 4 | import type { Page } from '@sveltejs/kit'; 5 | 6 | describe( 7 | 'Request parameters', 8 | () => { 9 | it('can get parameters from empty params and url', () => { 10 | const page = mockPage('https://localhost/'); 11 | 12 | expect(getRequestParams(page, true)).toStrictEqual({}); 13 | }); 14 | 15 | it('can get parameters from url', () => { 16 | const page = mockPage('https://localhost/?id=1'); 17 | 18 | expect(getRequestParams(page, true)).toStrictEqual({ id: '1' }); 19 | }); 20 | 21 | it('do not get params from url if there is no browser', () => { 22 | const page = mockPage('https://localhost/?id=1'); 23 | 24 | expect(getRequestParams(page, false)).toStrictEqual({}); 25 | }); 26 | }, 27 | testOptions 28 | ); 29 | 30 | function mockPage(url: string): Page { 31 | return { 32 | params: {}, 33 | url: new URL(url), 34 | route: { 35 | id: '' 36 | }, 37 | status: 200, 38 | error: null, 39 | data: {}, 40 | state: '', 41 | form: '' 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/Request.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@sveltejs/kit'; 2 | 3 | type Optional = T | null | undefined; 4 | type FieldName = string; 5 | 6 | /** */ 7 | export type RequestParameters = { 8 | page?: Optional; 9 | filters?: Optional>; 10 | sort?: Optional>; 11 | [key: string]: unknown; 12 | }; 13 | 14 | /** 15 | * Extracts all parameters from the URL based on Svelte's Page store. 16 | * 17 | * "$page.params", which corresponds to Route parameters that are defined in your 18 | * Svelte routes. With SvelteAdmin, routes can look like "/admin/[crud]/[operation]". 19 | * In this case, calling the "/admin/books/list" will set "crud" and "action" parameters in the Page store. 20 | * 21 | * When this function is called in the context of a web browser (hence the "browser" boolean arg), we will also merge the url.searchParams object, which is an iterator containing all elements from the QueryString, like "?crud=...&operation=..." or "?id=...". 22 | * 23 | * Route params will always have precedence over QueryString, to avoid unexpected overrides. 24 | */ 25 | export function getRequestParams(page: Page, browser: boolean): RequestParameters { 26 | let params = { ...page.params }; 27 | 28 | if (browser) { 29 | params = { ...Object.fromEntries(page.url.searchParams), ...params }; 30 | } 31 | 32 | return params; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/StateProcessor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { testOptions } from '$lib/TestOptions'; 3 | import { 4 | CallbackStateProcessor, 5 | BaseCrudOperation, 6 | type CrudOperation, 7 | type StateProcessorCallback 8 | } from '$lib'; 9 | 10 | describe( 11 | 'Callback State processor', 12 | () => { 13 | it( 14 | 'executes the callback', 15 | async () => { 16 | let callbackCalled = false; 17 | const callback: StateProcessorCallback = async (data) => { 18 | expect(callbackCalled).toBe(false); 19 | expect(data).toBe(false); 20 | callbackCalled = true; 21 | }; 22 | const processor = new CallbackStateProcessor(callback); 23 | expect(callbackCalled).toStrictEqual(false); 24 | 25 | await processor.process(false, mockOperation(), {}); 26 | 27 | expect(callbackCalled).toStrictEqual(true); 28 | }, 29 | testOptions 30 | ); 31 | }, 32 | testOptions 33 | ); 34 | 35 | function mockOperation(): CrudOperation { 36 | return new (class extends BaseCrudOperation { 37 | constructor(...args: unknown[]) { 38 | // @ts-ignore 39 | super(...args); 40 | } 41 | })('', '', '', [], [], {}); 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/StateProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { CrudOperation, RequestParameters } from '$lib'; 2 | 3 | /** */ 4 | export type StateProcessorInput = T | Array | null; 5 | 6 | /** */ 7 | export interface StateProcessor { 8 | process( 9 | data: StateProcessorInput, 10 | operation: CrudOperation, 11 | requestParameters: RequestParameters 12 | ): Promise; 13 | } 14 | 15 | /** */ 16 | export type StateProcessorCallback = ( 17 | data: StateProcessorInput, 18 | operation: CrudOperation, 19 | requestParameters: RequestParameters 20 | ) => void; 21 | 22 | /** */ 23 | export class CallbackStateProcessor implements StateProcessor { 24 | private readonly _callback: StateProcessorCallback; 25 | 26 | constructor(callback: StateProcessorCallback) { 27 | this._callback = callback; 28 | } 29 | 30 | process( 31 | data: StateProcessorInput, 32 | operation: CrudOperation, 33 | requestParameters: RequestParameters 34 | ): Promise { 35 | return new Promise((resolve) => { 36 | this._callback(data, operation, requestParameters); 37 | resolve(); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/StateProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { testOptions } from '$lib/TestOptions'; 3 | import { CallbackStateProvider, BaseCrudOperation, type CrudOperation } from '$lib'; 4 | 5 | describe( 6 | 'Callback State Provider', 7 | () => { 8 | it( 9 | 'executes the callback', 10 | async () => { 11 | const provider = new CallbackStateProvider(async () => true); 12 | 13 | const value = await provider.provide(mockOperation(), {}); 14 | 15 | expect(value).toBe(true); 16 | }, 17 | testOptions 18 | ); 19 | }, 20 | testOptions 21 | ); 22 | 23 | function mockOperation(): CrudOperation { 24 | return new (class extends BaseCrudOperation { 25 | constructor(...args: unknown[]) { 26 | // @ts-ignore 27 | super(...args); 28 | } 29 | })('', '', '', [], [], {}); 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/StateProvider.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedResults, CrudOperation, RequestParameters } from '$lib'; 2 | 3 | /** */ 4 | export type StateProviderResult = Promise | Array | null>; 5 | 6 | /** */ 7 | export interface StateProvider { 8 | provide(action: CrudOperation, requestParameters: RequestParameters): StateProviderResult; 9 | } 10 | 11 | /** */ 12 | export type StateProviderCallback = ( 13 | action: CrudOperation, 14 | requestParameters: RequestParameters 15 | ) => StateProviderResult; 16 | 17 | /** */ 18 | export class CallbackStateProvider implements StateProvider { 19 | private readonly _callback: StateProviderCallback; 20 | 21 | constructor(callback: StateProviderCallback) { 22 | this._callback = callback; 23 | } 24 | 25 | provide(action: CrudOperation, requestParameters: RequestParameters): StateProviderResult { 26 | return this._callback(action, requestParameters); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/TestOptions.ts: -------------------------------------------------------------------------------- 1 | import type { TestOptions } from 'vitest'; 2 | 3 | export const testOptions: TestOptions = { 4 | repeats: process.env.REPEAT ? parseInt(process.env.REPEAT) : undefined 5 | }; 6 | -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import { init, addMessages } from 'svelte-i18n'; 2 | 3 | import en from './translations/en'; 4 | import fr from './translations/fr'; 5 | 6 | /** */ 7 | export type Dictionary = { [key: string]: string }; 8 | 9 | /** */ 10 | export type Dictionaries = { [key: string]: Dictionary }; 11 | 12 | const adminDictionaries: Dictionaries = { 13 | en: en, 14 | fr: fr 15 | }; 16 | 17 | /** */ 18 | export function initLocale(locale: Intl.Locale | string, dictionaries: Dictionaries = {}) { 19 | locale = locale.toString(); 20 | 21 | // Validate locale. 22 | validateLocale(locale); 23 | 24 | // Admin translations 25 | for (const dictionaryLocale in adminDictionaries) { 26 | addMessages(dictionaryLocale, adminDictionaries[dictionaryLocale]); 27 | } 28 | 29 | // User-based translations 30 | for (const dictionaryLocale in dictionaries) { 31 | addMessages(dictionaryLocale, dictionaries[dictionaryLocale]); 32 | } 33 | 34 | init({ 35 | fallbackLocale: adminDictionaries[locale] ? locale : 'en', 36 | initialLocale: locale 37 | }); 38 | } 39 | 40 | function validateLocale(locale: string) { 41 | try { 42 | new Intl.Locale(locale); 43 | } catch (e) { 44 | console.error(`Locale "${locale}" is not a valid standard locale.`); 45 | throw e; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './Actions'; 3 | export * from './Config'; 4 | 5 | export * from './Crud'; 6 | export * from './Crud/Form'; 7 | export * from './Crud/Operations'; 8 | 9 | export * from './Dashboard'; 10 | export * from './DataTable'; 11 | export * from './Filter'; 12 | 13 | export * from './Fields'; 14 | export * from './Fields/Checkbox'; 15 | export * from './Fields/Columns'; 16 | export * from './Fields/CrudEntity'; 17 | export * from './Fields/Date'; 18 | export * from './Fields/KeyValueObject'; 19 | export * from './Fields/Number'; 20 | export * from './Fields/Tabs'; 21 | export * from './Fields/Textarea'; 22 | export * from './Fields/Text'; 23 | export * from './Fields/Toggle'; 24 | export * from './Fields/Url'; 25 | export * from './Fields/Array'; 26 | export * from './Fields/Email'; 27 | 28 | export * from './i18n'; 29 | export * from './Menu'; 30 | export * from './Notification'; 31 | export * from './Pagination'; 32 | export * from './Request'; 33 | export * from './StateProcessor'; 34 | export * from './StateProvider'; 35 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Columns/Columns.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | {#each field.fields as column} 23 | 30 | {#if column.label || column.name} 31 | {#if operation.name === 'view'} 32 |

{$_(column.label || column.name)}

33 | {:else} 34 | {$_(column.label || column.name)} 35 | {/if} 36 | {/if} 37 | {#each column.fields as columnedField} 38 | 47 | {/each} 48 |
49 | {/each} 50 |
51 |
52 | 53 | 58 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Columns/Columns.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render } from '@testing-library/svelte'; 3 | import '@testing-library/jest-dom'; 4 | import { carbon } from '$lib/themes/svelte'; 5 | import { testOptions } from '$lib/TestOptions'; 6 | import { 7 | CallbackStateProcessor, 8 | CallbackStateProvider, 9 | Columns, 10 | CrudDefinition, 11 | DashboardDefinition, 12 | initLocale, 13 | TextField 14 | } from '$lib'; 15 | import { View } from '$lib/Crud/Operations'; 16 | import ComponentToTest from './Columns.svelte'; 17 | import TextComponent from '../ViewFieldsComponents/DefaultField.svelte'; 18 | 19 | describe( 20 | 'Columns component', 21 | () => { 22 | it('can be instantiated', async () => { 23 | const props = mockComponentProps( 24 | new Columns('columns', 'Columns label', [ 25 | { 26 | name: 'column_1', 27 | label: 'Column 1', 28 | fields: [new TextField('text', 'Text field')] 29 | } 30 | ]) 31 | ); 32 | 33 | const rendered = render(ComponentToTest, props); 34 | 35 | const h2 = rendered.container.querySelector('h2'); 36 | expect(h2).toBeDefined(); 37 | expect(h2?.innerHTML).toStrictEqual('Column 1'); 38 | }); 39 | }, 40 | testOptions 41 | ); 42 | 43 | function mockComponentProps(field: Columns) { 44 | const dashboard = new DashboardDefinition({ 45 | theme: carbon, 46 | adminConfig: {}, 47 | cruds: [ 48 | new CrudDefinition({ 49 | name: 'test_field', 50 | label: { singular: 'Test Field', plural: 'Test Fields' }, 51 | operations: [new View([field])], 52 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)), 53 | stateProcessor: new CallbackStateProcessor(() => {}) 54 | }) 55 | ] 56 | }); 57 | 58 | initLocale('fr'); 59 | 60 | return { 61 | FieldComponent: TextComponent, 62 | field: field, 63 | operation: (dashboard.cruds[0] as CrudDefinition).options.operations[0], 64 | entityObject: { test_field: 'default_value' }, 65 | value: 'default_value', 66 | theme: dashboard.theme 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudDelete.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | {#if !data} 39 | 40 | {$_('error.crud.entity.not_found')} 41 | 42 | {:else} 43 | 44 | {$_('crud.delete.are_you_sure', { 45 | values: { 46 | id: requestParameters[crud.options.identifierFieldName] || '', 47 | name: $_(crud.options.label.singular) 48 | } 49 | })} 50 | 51 | 54 | 57 | {/if} 58 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudEdit.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | {#await defaultData} 46 | 47 | 48 | 49 | 50 | {:then data} 51 | {#if !data} 52 | 53 | {$_('error.crud.entity.not_found')} 54 | 55 | {:else} 56 | 69 | 70 |

{$_(operation.label, { values: { name: $_(crud.options.label.singular) } })}

71 |
72 |
73 | {/if} 74 | {/await} 75 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudForm.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
58 | 59 | 60 | {#each fields as field} 61 | {#if field instanceof Tabs || field instanceof Columns} 62 | 63 | {:else} 64 | 65 | 66 | 67 | {/if} 68 | {/each} 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudFormField.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | 55 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudList.svelte: -------------------------------------------------------------------------------- 1 | 157 | 158 | 174 |

175 | {$_(operation.label, { values: { name: $_(crud.options.label.plural) } })} 176 |

177 |
178 | {#if showPagination && paginator} 179 | 186 | {/if} 187 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudNew.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | 37 |

{$_(operation.label, { values: { name: $_(crud.options.label.singular) } })}

38 |
39 |
40 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudView.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |

{$_(operation.label, { values: { name: $_(crud.options.label.singular) } })}

37 | 38 | {#await providerResultPromise} 39 | 40 | 41 | 42 | {:then entityObject} 43 | {#if !entityObject} 44 | 45 | {$_('error.crud.entity.not_found')} 46 | 47 | {:else} 48 | {#each fields as field} 49 | 56 | {/each} 57 | {/if} 58 | {/await} 59 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Crud/CrudViewField.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if fullSize} 28 | 29 | {:else} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {/if} 41 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/Dashboard/Dashboard.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 43 | 44 | {#if !currentCrud} 45 | 46 | {#if crud} 47 | {$_('error.crud.could_not_find_crud_name', { values: { crud } })} 48 | {:else} 49 | {$_('error.crud.no_crud_specified')} 50 | {/if} 51 | 52 | {/if} 53 | {#if currentCrud && !currentCrudOperation} 54 | 55 | {#if operation} 56 | {$_('error.crud.could_not_find_operation_name', { 57 | values: { crud, operation } 58 | })} 59 | {:else} 60 | {$_('error.crud.no_operation_specified', { values: { crud } })} 61 | {/if} 62 | 63 | {/if} 64 | {#if currentCrud && currentCrudOperation && !themeComponent} 65 | 66 | {$_('error.crud.could_not_find_component', { 67 | values: { crud, operation } 68 | })} 69 | 70 | {/if} 71 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/DataTable/Toolbar/DataTableToolbar.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | {#if actions.length} 59 | 60 | 61 | {#each actions as action} 62 | 63 | {/each} 64 | 65 | 66 | {/if} 67 | 68 | {#if filters.length} 69 | 70 | !!i).length > 0}> 71 | 72 | 73 | {$_('datatable.filters.menu_title')} 74 | 75 |
76 | {#each filters as filter} 77 |
78 | 79 | {/each} 80 |
81 | 85 | 89 | 90 |
91 |
92 | {/if} 93 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/DataTable/Toolbar/ToolbarAction.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if action instanceof UrlAction} 14 | 22 | {:else if action instanceof CallbackAction} 23 | 31 | {:else} 32 | 36 | {/if} 37 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/DataTable/Toolbar/ToolbarFilter.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/DataTable/actions/ItemActions.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | {#each actions as action} 16 | 17 | 18 | 19 | {/each} 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/DataTable/actions/SingleAction.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if action instanceof UrlAction} 15 | 16 | {$_(action.label)} 17 | 18 | {:else if action instanceof CallbackAction} 19 | 26 | {:else} 27 | 31 | {/if} 32 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FilterComponents/BooleanFilter.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 65 | 66 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FilterComponents/DateRangeFilter.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FilterComponents/Internal/FilterContainer.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 14 | 15 | 18 |
19 | 20 | 38 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FilterComponents/NumericFilter.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 20 | 21 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FilterComponents/TextFilter.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/ArrayField.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | {$_(field.label)} 25 | 26 | 27 | 28 | 29 | 30 | {#each value || [] as itemValue} 31 | 32 | 33 | 40 | 41 | 42 | {:else} 43 | 44 | 45 | 52 | 53 | 54 | {/each} 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/CheckboxField.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 32 | {#if field.options.help} 33 | 34 |
38 | {field.options.help} 39 |
40 | {/if} 41 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/ColumnsField.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/CrudEntityField.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | {#if !crud} 38 | 39 | {$_('error.crud.could_not_find_crud_name', { values: { crud: field.options.crud_name } })} 40 | 41 | {:else} 42 | {#await fetchList()} 43 | 44 | {:then data} 45 | {@const values = data} 46 | 61 | {:catch error} 62 | 63 | {$_('error.crud.form.entity_field_list_fetch_error', { values: { message: error.message } })} 64 | 65 | {/await} 66 | {/if} 67 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/DateField.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 50 | 51 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/DefaultField.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/DefaultField.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render } from '@testing-library/svelte'; 3 | import '@testing-library/jest-dom'; 4 | import { testOptions } from '$lib/TestOptions'; 5 | import ComponentToTest from './DefaultField.svelte'; 6 | import { TextField } from '$lib'; 7 | 8 | describe( 9 | 'DefaultField component', 10 | () => { 11 | it('can be instantiated with undefined', async () => { 12 | const rendered = render(ComponentToTest, { 13 | field: new TextField('default_field'), 14 | value: undefined 15 | }); 16 | 17 | const element = rendered.container; 18 | expect(element).toBeDefined(); 19 | const input = rendered.container.querySelector('input'); 20 | expect(input).toBeDefined(); 21 | expect(input?.value).toStrictEqual(''); 22 | }); 23 | 24 | it('can be instantiated empty string', async () => { 25 | const rendered = render(ComponentToTest, { 26 | field: new TextField('default_field'), 27 | value: '' 28 | }); 29 | 30 | const element = rendered.container; 31 | expect(element).toBeDefined(); 32 | const input = rendered.container.querySelector('input'); 33 | expect(input).toBeDefined(); 34 | expect(input?.value).toStrictEqual(''); 35 | }); 36 | 37 | it('can be instantiated specific value', async () => { 38 | const rendered = render(ComponentToTest, { 39 | field: new TextField('default_field'), 40 | value: 'Some value' 41 | }); 42 | 43 | const element = rendered.container; 44 | expect(element).toBeDefined(); 45 | const input = rendered.container.querySelector('input'); 46 | expect(input).toBeDefined(); 47 | expect(input?.value).toStrictEqual('Some value'); 48 | }); 49 | }, 50 | testOptions 51 | ); 52 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/EmailField.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/EmailField.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render } from '@testing-library/svelte'; 3 | import '@testing-library/jest-dom'; 4 | import { testOptions } from '$lib/TestOptions'; 5 | import ComponentToTest from './EmailField.svelte'; 6 | import { EmailField } from '$lib'; 7 | 8 | describe( 9 | 'EmailField component', 10 | () => { 11 | it('can be instantiated with undefined', async () => { 12 | const rendered = render(ComponentToTest, { 13 | field: new EmailField('email_field'), 14 | value: undefined 15 | }); 16 | 17 | const element = rendered.container; 18 | expect(element).toBeDefined(); 19 | expect(rendered.container.querySelector('input')).toBeDefined(); 20 | }); 21 | 22 | it('can be instantiated empty string', async () => { 23 | const rendered = render(ComponentToTest, { 24 | field: new EmailField('email_field'), 25 | value: '' 26 | }); 27 | 28 | const element = rendered.container; 29 | expect(element).toBeDefined(); 30 | const input = rendered.container.querySelector('input'); 31 | expect(input).toBeDefined(); 32 | expect(input?.value).toStrictEqual(''); 33 | }); 34 | 35 | it('can be instantiated specific value', async () => { 36 | const rendered = render(ComponentToTest, { 37 | field: new EmailField('email_field'), 38 | value: 'test@dummy.localhost' 39 | }); 40 | 41 | const element = rendered.container; 42 | expect(element).toBeDefined(); 43 | const input = rendered.container.querySelector('input'); 44 | expect(input).toBeDefined(); 45 | expect(input?.value).toStrictEqual('test@dummy.localhost'); 46 | }); 47 | }, 48 | testOptions 49 | ); 50 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/KeyValueObjectField.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 |
51 | 52 | {#each valueEntries as entry, i} 53 | {@const key = entry[0]} 54 | {@const entryValue = entry[1]} 55 | {@const inputId = field.name + '_' + key.replace(/[^a-z0-9_-]/gi, '_')} 56 | {@const inputName = field.name + '[' + key + ']'} 57 | 58 | 59 | 62 | {#if key.length} 63 | 64 | {/if} 65 | 66 | 67 | = 0} 69 | warn={key.length === 0} 70 | invalidText={$_('error.crud.form.object.duplicate_key')} 71 | size="sm" 72 | data-key={i} 73 | disabled={field.options.disabled} 74 | bind:value={valueEntries[i][0]} 75 | /> 76 | 77 | 78 | 79 | 80 | 81 | 87 | 88 | {#if !field.options.disabled} 89 | 90 | 93 | 94 | {/if} 95 | 96 | {/each} 97 | {#if !field.options.disabled} 98 | 99 | 102 | 103 | {/if} 104 | 105 |
106 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/NumberField.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/TabsField.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/TextField.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | -------------------------------------------------------------------------------- /src/lib/themes/svelte/carbon/FormFieldsComponents/TextareaField.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |