├── .eslintignore
├── static
├── .nojekyll
└── favicon.png
├── .npmrc
├── src
├── routes
│ ├── +layout.ts
│ ├── docs
│ │ └── +page.server.ts
│ ├── admin
│ │ └── [crud]
│ │ │ └── [operation]
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ └── +page.svelte
├── lib
│ ├── themes
│ │ └── svelte
│ │ │ ├── index.ts
│ │ │ └── carbon
│ │ │ ├── ViewFieldsComponents
│ │ │ ├── DefaultField.svelte
│ │ │ ├── ViewLabel.svelte
│ │ │ ├── EmailField.svelte
│ │ │ ├── DateField.svelte
│ │ │ ├── CheckboxField.svelte
│ │ │ ├── ToggleField.svelte
│ │ │ ├── NumberField.svelte
│ │ │ ├── TabsField.svelte
│ │ │ ├── ColumnsField.svelte
│ │ │ ├── KeyValueObjectField.svelte
│ │ │ ├── ArrayField.svelte
│ │ │ ├── CheckboxField.test.ts
│ │ │ ├── UrlField.svelte
│ │ │ ├── DefaultField.test.ts
│ │ │ ├── DateField.test.ts
│ │ │ ├── EmailField.test.ts
│ │ │ ├── CrudEntityField.svelte
│ │ │ ├── ArrayField.test.ts
│ │ │ ├── KeyValueObjectField.test.ts
│ │ │ └── NumberField.test.ts
│ │ │ ├── DataTable
│ │ │ ├── Toolbar
│ │ │ │ ├── ToolbarFilter.svelte
│ │ │ │ ├── ToolbarAction.svelte
│ │ │ │ └── DataTableToolbar.svelte
│ │ │ └── actions
│ │ │ │ ├── ItemActions.svelte
│ │ │ │ └── SingleAction.svelte
│ │ │ ├── FormFieldsComponents
│ │ │ ├── EmailField.svelte
│ │ │ ├── DefaultField.svelte
│ │ │ ├── TabsField.svelte
│ │ │ ├── ColumnsField.svelte
│ │ │ ├── NumberField.svelte
│ │ │ ├── TextareaField.svelte
│ │ │ ├── TextField.svelte
│ │ │ ├── ToggleField.svelte
│ │ │ ├── UrlField.svelte
│ │ │ ├── CheckboxField.svelte
│ │ │ ├── EmailField.test.ts
│ │ │ ├── ArrayField.svelte
│ │ │ ├── DefaultField.test.ts
│ │ │ ├── DateField.svelte
│ │ │ ├── CrudEntityField.svelte
│ │ │ └── KeyValueObjectField.svelte
│ │ │ ├── FilterComponents
│ │ │ ├── TextFilter.svelte
│ │ │ ├── NumericFilter.svelte
│ │ │ ├── Internal
│ │ │ │ └── FilterContainer.svelte
│ │ │ ├── DateRangeFilter.svelte
│ │ │ └── BooleanFilter.svelte
│ │ │ ├── lib
│ │ │ └── ThemeChangeMenu.ts
│ │ │ ├── Menu
│ │ │ ├── TopMenu.svelte
│ │ │ ├── TopLeftMenu.svelte
│ │ │ ├── SideMenu.svelte
│ │ │ └── TopRightMenu.svelte
│ │ │ ├── Crud
│ │ │ ├── CrudNew.svelte
│ │ │ ├── CrudFormField.svelte
│ │ │ ├── CrudViewField.svelte
│ │ │ ├── CrudDelete.svelte
│ │ │ ├── CrudView.svelte
│ │ │ ├── CrudForm.svelte
│ │ │ ├── CrudEdit.svelte
│ │ │ └── CrudList.svelte
│ │ │ ├── Tabs
│ │ │ └── Tabs.svelte
│ │ │ ├── Layout
│ │ │ └── AdminLayout.svelte
│ │ │ ├── Columns
│ │ │ ├── Columns.svelte
│ │ │ └── Columns.test.ts
│ │ │ ├── Dashboard
│ │ │ └── Dashboard.svelte
│ │ │ └── index.ts
│ ├── TestOptions.ts
│ ├── Fields
│ │ ├── Toggle.ts
│ │ ├── Email.ts
│ │ ├── Checkbox.ts
│ │ ├── Url.ts
│ │ ├── Textarea.ts
│ │ ├── Date.ts
│ │ ├── Number.ts
│ │ ├── Text.ts
│ │ ├── Tabs.ts
│ │ ├── Array.ts
│ │ ├── KeyValueObject.ts
│ │ ├── CrudEntity.ts
│ │ ├── index.ts
│ │ └── Columns.ts
│ ├── Config.ts
│ ├── Pagination.ts
│ ├── Layout
│ │ └── Icon.svelte
│ ├── Menu.ts
│ ├── DataTable.ts
│ ├── StateProvider.test.ts
│ ├── StateProvider.ts
│ ├── index.ts
│ ├── StateProcessor.ts
│ ├── Request.test.ts
│ ├── StateProcessor.test.ts
│ ├── i18n.ts
│ ├── Request.ts
│ ├── Filter.ts
│ ├── DataTable.test.ts
│ ├── Notification.ts
│ ├── Crud
│ │ ├── Form.ts
│ │ ├── Form.test.ts
│ │ ├── index.test.ts
│ │ ├── index.ts
│ │ └── Operations.ts
│ ├── translations
│ │ ├── en.ts
│ │ └── fr.ts
│ ├── types.ts
│ ├── Actions.test.ts
│ ├── Dashboard.test.ts
│ ├── Actions.ts
│ └── Dashboard.ts
├── testApp
│ ├── translations
│ │ └── fr.ts
│ ├── internal
│ │ ├── authorsInternal.ts
│ │ ├── booksInternal.ts
│ │ ├── testsInternal.ts
│ │ └── memoryStorage.ts
│ ├── Dashboard.ts
│ ├── AuthorCrud.ts
│ └── BookCrud.ts
├── app.d.ts
└── app.html
├── docs-src
├── backoffice_edit.png
├── backoffice_list.png
└── backoffice_view.png
├── bin
├── templates
│ ├── carbonViewField.svelte
│ ├── carbonFormField.svelte
│ └── TemplateField.ts
├── package.json
├── pnpm-lock.yaml
└── create_theme.mjs
├── .prettierignore
├── .prettierrc
├── .gitignore
├── typedoc.json
├── test_cli.js
├── .npmignore
├── tsconfig.json
├── vite.config.ts
├── svelte.config.js
├── .github
└── workflows
│ ├── CI.yaml
│ └── pages.yaml
├── .eslintrc.cjs
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/src/routes/+layout.ts:
--------------------------------------------------------------------------------
1 | export const trailingSlash = 'always';
2 |
--------------------------------------------------------------------------------
/src/routes/docs/+page.server.ts:
--------------------------------------------------------------------------------
1 | export const prerender = false;
2 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/index.ts:
--------------------------------------------------------------------------------
1 | export { default as carbon } from './carbon';
2 |
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Orbitale/SvelteAdmin/HEAD/static/favicon.png
--------------------------------------------------------------------------------
/docs-src/backoffice_edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Orbitale/SvelteAdmin/HEAD/docs-src/backoffice_edit.png
--------------------------------------------------------------------------------
/docs-src/backoffice_list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Orbitale/SvelteAdmin/HEAD/docs-src/backoffice_list.png
--------------------------------------------------------------------------------
/docs-src/backoffice_view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Orbitale/SvelteAdmin/HEAD/docs-src/backoffice_view.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/bin/templates/carbonViewField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {value}
10 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/DefaultField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {value}
10 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/testApp/translations/fr.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '$lib/i18n';
2 |
3 | const dictionary: Dictionary = {
4 | Book: 'Livre',
5 | Books: 'Livres',
6 | Homepage: 'Accueil',
7 | Title: 'Titre'
8 | };
9 |
10 | export default dictionary;
11 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/ViewLabel.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | {$_(field.label)}
10 |
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/EmailField.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {#if value !== '' && value && value.length > 0}
8 | {value}
9 | {:else}
10 | -
11 | {/if}
12 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "entryPoints": ["src/lib/*.ts", "src/lib/**/*.ts", "src/themes/*.ts", "src/themes/**/*.ts"],
3 | "out": "build/apidocs",
4 | "exclude": ["**/*+(index|.spec|.e2e).ts"],
5 | "disableGit": true,
6 | "disableSources": true,
7 | "excludeExternals": false,
8 | "excludeNotDocumented": true,
9 | "excludePrivate": true,
10 | "excludeReferences": true,
11 | "plugin": ["typedoc-plugin-mdn-links"]
12 | }
13 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/test_cli.js:
--------------------------------------------------------------------------------
1 | import { load_config } from './node_modules/@sveltejs/kit/src/core/config/index.js';
2 | import { all } from './node_modules/@sveltejs/kit/src/core/sync/sync.js';
3 |
4 | (async () => {
5 | console.info('Testing.');
6 |
7 | const conf = await load_config();
8 |
9 | const manifest_data = all(conf, 'development');
10 |
11 | const routes = manifest_data.manifest_data.routes;
12 |
13 | console.info(routes);
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/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/themes/svelte/carbon/ViewFieldsComponents/DateField.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 | {value.substring(0, 10)}
17 |
--------------------------------------------------------------------------------
/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/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/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/themes/svelte/carbon/DataTable/Toolbar/ToolbarFilter.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/routes/admin/[crud]/[operation]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { dashboard } from '../../../../testApp/Dashboard';
2 |
3 | export const prerender = true;
4 |
5 | /** @type {import('./$types').EntryGenerator} */
6 | export function entries() {
7 | const routes = [];
8 |
9 | for (const crud of dashboard.cruds) {
10 | for (const operation of crud.options.operations) {
11 | routes.push({
12 | crud: crud.name,
13 | operation: operation.name
14 | });
15 | }
16 | }
17 |
18 | return routes;
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/CheckboxField.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 | {#if value}
13 |
14 | {:else}
15 |
16 | {/if}
17 |
--------------------------------------------------------------------------------
/bin/templates/carbonFormField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
16 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/ToggleField.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | setTimeout(() => (boundValue = baseValue))}
16 | hideLabel
17 | labelA=""
18 | labelB=""
19 | />
20 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/NumberField.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 | {#if value !== undefined && isNaN(value)}
11 | NaN
12 | {:else if value !== 0 && !value}
13 | No value
14 | {:else}
15 | {value}
16 | {/if}
17 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/FormFieldsComponents/EmailField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
17 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/DefaultField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
17 |
--------------------------------------------------------------------------------
/src/routes/admin/[crud]/[operation]/+page.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | {#key $page}
14 |
21 | {/key}
22 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/TabsField.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/TabsField.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | //
16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
17 | // from the referenced tsconfig.json - TypeScript does not merge them in
18 | }
19 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/ColumnsField.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/ColumnsField.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FilterComponents/TextFilter.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
19 |
20 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/NumberField.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vite';
3 | import { configDefaults } from 'vitest/config';
4 | import { svelteTesting } from '@testing-library/svelte/vite';
5 | import { resolve } from 'path';
6 |
7 | export default defineConfig({
8 | plugins: [sveltekit(), svelteTesting()],
9 | resolve: {
10 | alias: {
11 | $lib: resolve(__dirname, 'src/lib')
12 | }
13 | },
14 | test: {
15 | include: ['src/**/*.{test,spec}.ts'],
16 | exclude: [...configDefaults.exclude, '**/build/**', '**/.svelte-kit/**', '**/dist/**'],
17 | globals: true,
18 | environment: 'jsdom',
19 | coverage: {
20 | include: ['src/lib/']
21 | }
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FilterComponents/NumericFilter.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
20 |
21 |
--------------------------------------------------------------------------------
/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/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/ViewFieldsComponents/KeyValueObjectField.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 | {#if value === undefined}
17 | No value
18 | {:else if displayValue === undefined}
19 | Not found
20 | {:else}
21 | {displayValue}
22 | {/if}
23 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/TextareaField.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
26 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/TextField.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
26 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-static';
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: [vitePreprocess()],
9 |
10 | kit: {
11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter.
13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
14 | adapter: adapter({
15 | pages: 'build/',
16 | fallback: 'index.html'
17 | })
18 | }
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/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/testApp/internal/authorsInternal.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker';
2 | import { InMemoryStorage } from './memoryStorage';
3 |
4 | let storage: null | InMemoryStorage = null;
5 |
6 | export type Author = {
7 | id: number | string;
8 | first_name: string;
9 | last_name: string;
10 | bio: string;
11 | };
12 |
13 | export function getStorage(): InMemoryStorage {
14 | if (!storage) {
15 | storage = new InMemoryStorage('Author', getBase);
16 | }
17 |
18 | return storage;
19 | }
20 |
21 | function getBase(): Array {
22 | return Array(25)
23 | .fill(undefined)
24 | .map(() => {
25 | return {
26 | id: faker.string.uuid(),
27 | first_name: faker.person.firstName(),
28 | last_name: faker.person.lastName(),
29 | bio: faker.person.bio()
30 | };
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/FilterComponents/Internal/FilterContainer.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 | {$_(filter.label || filter.field)}
12 |
13 |
14 |
15 |
18 |
19 |
20 |
38 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/ToggleField.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/lib/ThemeChangeMenu.ts:
--------------------------------------------------------------------------------
1 | import { type ActionIcon, type ActionOptions, CallbackAction } from '$lib/Actions';
2 | import { type MenuLink, Submenu } from '$lib/Menu';
3 |
4 | type Optional = T | null | undefined;
5 |
6 | type CarbonTheme = 'white' | 'g10' | 'g80' | 'g90' | 'g100';
7 |
8 | export default class ThemeChangeMenu extends Submenu {
9 | static readonly availableThemes: Array = ['white', 'g10', 'g80', 'g90', 'g100'];
10 |
11 | constructor(icon?: Optional, options?: ActionOptions) {
12 | const links: Array = ThemeChangeMenu.availableThemes.map((theme) => {
13 | return new CallbackAction('carbon.theme.' + theme, undefined, () => this.changeTheme(theme));
14 | });
15 |
16 | super('carbon.theme.change_action', icon, links, options);
17 | }
18 |
19 | changeTheme(newTheme: CarbonTheme) {
20 | document.documentElement.setAttribute('theme', newTheme);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/ArrayField.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 | {#each value || [] as itemValue}
21 |
22 |
23 |
24 | {/each}
25 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/FormFieldsComponents/UrlField.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
27 |
--------------------------------------------------------------------------------
/src/testApp/internal/booksInternal.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker';
2 | import { InMemoryStorage } from './memoryStorage';
3 |
4 | let storage: null | InMemoryStorage = null;
5 |
6 | export type Book = {
7 | id: number | string;
8 | title: string;
9 | description: string;
10 | numberOfPages: number;
11 | publishedAt: string;
12 | };
13 |
14 | export function getStorage(): InMemoryStorage {
15 | if (!storage) {
16 | storage = new InMemoryStorage('Book', getBase);
17 | }
18 |
19 | return storage;
20 | }
21 |
22 | function getBase(): Array {
23 | return Array(25)
24 | .fill(undefined)
25 | .map((_: undefined, i: number) => {
26 | return {
27 | id: faker.string.uuid(),
28 | title: i + 1 + ' ' + faker.music.songName(),
29 | description: faker.lorem.lines(3),
30 | numberOfPages: faker.number.int({ min: 50, max: 800 }),
31 | publishedAt: faker.date.anytime().toISOString()
32 | };
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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 | await action.call(item)}
21 | icon={action.icon}
22 | {...action.options.htmlAttributes}
23 | >
24 | {$_(action.label)}
25 |
26 | {:else}
27 |
31 | {/if}
32 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/FilterComponents/DateRangeFilter.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/CheckboxField.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 './CheckboxField.svelte';
6 |
7 | describe(
8 | 'CheckboxField component',
9 | () => {
10 | it('can be instantiated with value "true"', async () => {
11 | const rendered = render(ComponentToTest, {
12 | value: true
13 | });
14 |
15 | const svg = rendered.container.querySelector('svg') as SVGSVGElement;
16 | expect(svg).toBeDefined();
17 | expect(svg).toBeInstanceOf(SVGSVGElement);
18 | expect(svg.style.color).toBe('rgb(0, 170, 0)');
19 | });
20 |
21 | it('can be instantiated with value "false"', async () => {
22 | const rendered = render(ComponentToTest, {
23 | value: false
24 | });
25 |
26 | const svg = rendered.container.querySelector('svg') as SVGSVGElement;
27 | expect(svg).toBeDefined();
28 | expect(svg).toBeInstanceOf(SVGSVGElement);
29 | expect(svg.style.color).toBe('rgb(170, 0, 0)');
30 | });
31 | },
32 | testOptions
33 | );
34 |
--------------------------------------------------------------------------------
/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/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/themes/svelte/carbon/Menu/TopMenu.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
23 |
24 |
25 |
26 |
27 |
28 | {#if right_links.length}
29 |
30 | {/if}
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/UrlField.svelte:
--------------------------------------------------------------------------------
1 |
34 |
35 | {#if valid}
36 |
37 | {#if field.options.openInNewTab}
38 |
39 | {/if}
40 | {value}
41 |
42 | {:else if value.length > 0}
43 | Invalid URL!
44 | {:else}
45 | -
46 | {/if}
47 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/DataTable/Toolbar/ToolbarAction.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | {#if action instanceof UrlAction}
14 |
20 | {$_(action.label)}
21 |
22 | {:else if action instanceof CallbackAction}
23 | await action.call(...action_arguments)}
25 | icon={action.icon}
26 | kind={action.options?.buttonKind || 'primary'}
27 | {...action.options.htmlAttributes}
28 | >
29 | {$_(action.label)}
30 |
31 | {:else}
32 |
36 | {/if}
37 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/ViewFieldsComponents/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 |
7 | describe(
8 | 'DefaultField component',
9 | () => {
10 | it('can be instantiated with undefined', async () => {
11 | const rendered = render(ComponentToTest, {
12 | value: undefined
13 | });
14 |
15 | const element = rendered.container;
16 | expect(element).toBeDefined();
17 | expect(element.innerHTML).toStrictEqual('');
18 | });
19 |
20 | it('can be instantiated empty string', async () => {
21 | const rendered = render(ComponentToTest, {
22 | value: ''
23 | });
24 |
25 | const element = rendered.container;
26 | expect(element).toBeDefined();
27 | expect(element.innerHTML).toStrictEqual('');
28 | });
29 |
30 | it('can be instantiated specific value', async () => {
31 | const rendered = render(ComponentToTest, {
32 | value: 'Some value'
33 | });
34 |
35 | const element = rendered.container;
36 | expect(element).toBeDefined();
37 | expect(element.innerHTML).toStrictEqual('Some value');
38 | });
39 | },
40 | testOptions
41 | );
42 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/Crud/CrudFormField.svelte:
--------------------------------------------------------------------------------
1 |
33 |
34 |
55 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/Tabs/Tabs.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 | {#each field.fields as tab, i}
22 |
23 | {/each}
24 |
25 | {#each field.fields as tab}
26 |
27 | {#each tab.fields as tabbedField}
28 |
37 | {/each}
38 |
39 | {/each}
40 |
41 |
42 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/ViewFieldsComponents/DateField.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 './DateField.svelte';
6 |
7 | describe(
8 | 'DateField component',
9 | () => {
10 | it('can be instantiated with empty value', async () => {
11 | const rendered = render(ComponentToTest, {
12 | value: undefined
13 | });
14 |
15 | const element = rendered.container;
16 | expect(element).toBeDefined();
17 | expect(element.innerHTML).toStrictEqual('');
18 | });
19 |
20 | it('can be instantiated with Date object', async () => {
21 | const date = new Date('2024-01-01');
22 | const rendered = render(ComponentToTest, {
23 | value: date
24 | });
25 |
26 | const element = rendered.container;
27 | expect(element).toBeDefined();
28 | expect(element.innerHTML).toStrictEqual(`2024-01-01`);
29 | });
30 |
31 | it('can be instantiated with string value', async () => {
32 | const date = '2024-05-26 00:00:00 GMT+02:00';
33 | const rendered = render(ComponentToTest, {
34 | value: date
35 | });
36 |
37 | const element = rendered.container;
38 | expect(element).toBeDefined();
39 | expect(element.innerHTML).toStrictEqual('2024-05-26');
40 | });
41 | },
42 | testOptions
43 | );
44 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/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 |
7 | describe(
8 | 'EmailField component',
9 | () => {
10 | it('can be instantiated with undefined', async () => {
11 | const rendered = render(ComponentToTest, {
12 | value: undefined
13 | });
14 |
15 | const element = rendered.container;
16 | expect(element).toBeDefined();
17 | expect(element.innerHTML).toStrictEqual('-');
18 | });
19 |
20 | it('can be instantiated empty string', async () => {
21 | const rendered = render(ComponentToTest, {
22 | value: ''
23 | });
24 |
25 | const element = rendered.container;
26 | expect(element).toBeDefined();
27 | expect(element.innerHTML).toStrictEqual('-');
28 | });
29 |
30 | it('can be instantiated specific value', async () => {
31 | const rendered = render(ComponentToTest, {
32 | value: 'test@dummy.localhost'
33 | });
34 |
35 | const element = rendered.container;
36 | expect(element).toBeDefined();
37 | const tagInner = element.querySelector('span');
38 | expect(tagInner).toBeDefined();
39 | expect(tagInner?.innerHTML).toStrictEqual('test@dummy.localhost');
40 | });
41 | },
42 | testOptions
43 | );
44 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 | Svelte Admin demo app
15 |
16 |
17 | Svelte Admin is a (currently prototype) admin generator delivered as a Typescript library and a
18 | set of Svelte components as rendering system.
19 |
20 |
21 |
22 | If you detect any bug in this demo, feel free to clone the project or submit an issue in the
23 | Github Repository !
24 |
25 |
26 | Here are the available CRUDs for the demo app:
27 |
28 |
29 | {#each dashboard.cruds as crud}
30 |
31 |
32 | {$_(crud.options.label.plural)}
33 |
34 |
35 | {/each}
36 |
37 |
38 |
39 |
45 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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/Layout/AdminLayout.svelte:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
46 |
47 |
48 |
49 | {#if side_menu_links.length}
50 |
51 | {/if}
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/FormFieldsComponents/DateField.svelte:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
50 |
51 |
--------------------------------------------------------------------------------
/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/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/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/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 |
52 | {$_('crud.delete.yes_delete')}
53 |
54 |
55 | {$_('crud.delete.cancel')}
56 |
57 | {/if}
58 |
--------------------------------------------------------------------------------
/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/ViewFieldsComponents/CrudEntityField.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 | {#if !crud}
39 |
40 | {$_('error.crud.could_not_find_crud_name', { values: { crud: field.options.crud_name } })}
41 |
42 | {:else}
43 | {#await fetchData()}
44 |
45 | {:then data}
46 | {@const item = data[field.options.get_provider_operation.entity_field] ?? undefined}
47 | {#if !item}
48 |
49 | {$_('error.crud.form.entity_field_view_fetch_error', {
50 | values: { message: 'Field not found' }
51 | })}
52 |
53 | {:else}
54 | {item}
55 | {/if}
56 | {:catch error}
57 |
58 | {$_('error.crud.form.entity_field_view_fetch_error', { values: { message: error.message } })}
59 |
60 | {/await}
61 | {/if}
62 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/ArrayField.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 {
6 | CallbackStateProcessor,
7 | CallbackStateProvider,
8 | ArrayField,
9 | CrudDefinition,
10 | DashboardDefinition,
11 | initLocale,
12 | TextField,
13 | View
14 | } from '$lib';
15 | import ComponentToTest from './ArrayField.svelte';
16 | import carbon from '$lib/themes/svelte/carbon';
17 |
18 | describe(
19 | 'ArrayField component',
20 | () => {
21 | it('can be instantiated', async () => {
22 | const props = mockComponentProps(
23 | new ArrayField('array', 'Array label', new TextField('text', 'Text field'))
24 | );
25 |
26 | const rendered = render(ComponentToTest, props);
27 |
28 | const label = rendered.container.querySelector('div > strong');
29 | expect(label).toBeDefined();
30 | expect(label?.innerHTML).toStrictEqual('Text field');
31 | const valueElement = label?.parentElement?.nextElementSibling;
32 | expect(valueElement).toBeDefined();
33 | expect(valueElement?.childNodes).toBeDefined();
34 | expect(valueElement?.childNodes[0]).toBeInstanceOf(Text);
35 | const textNode: Text = valueElement?.childNodes[0] as Text;
36 | expect(textNode.wholeText).toStrictEqual('default_value');
37 | });
38 | },
39 | testOptions
40 | );
41 |
42 | function mockComponentProps(field: ArrayField) {
43 | const dashboard = new DashboardDefinition({
44 | theme: carbon,
45 | adminConfig: {},
46 | cruds: [
47 | new CrudDefinition({
48 | name: 'test_field',
49 | label: { singular: 'Test Field', plural: 'Test Fields' },
50 | operations: [new View([field])],
51 | stateProvider: new CallbackStateProvider(() => Promise.resolve(null)),
52 | stateProcessor: new CallbackStateProcessor(() => {})
53 | })
54 | ]
55 | });
56 |
57 | initLocale('fr');
58 |
59 | return {
60 | field: field,
61 | value: ['default_value'],
62 | operation: (dashboard.cruds[0] as CrudDefinition).options.operations[0],
63 | entityObject: { test_field: ['default_value'] },
64 | theme: dashboard.theme
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/src/testApp/internal/testsInternal.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker';
2 | import { type Book, getStorage as getBookStorage } from './booksInternal';
3 | import { InMemoryStorage } from './memoryStorage';
4 |
5 | let storage: null | InMemoryStorage = null;
6 |
7 | export type Test = {
8 | id: string;
9 | text_field: string;
10 | textarea_field: string;
11 | checkbox_field: boolean;
12 | number_field: number;
13 | toggle_field: boolean;
14 | url_field: string;
15 | path_field: string;
16 | date_field: Date;
17 | key_value_object_field: object;
18 | entities_array_field: Array;
19 | array_field: Array;
20 | [key: string]: unknown;
21 | };
22 |
23 | export function getStorage(): InMemoryStorage {
24 | if (!storage) {
25 | storage = new InMemoryStorage('Test', getBase);
26 | }
27 |
28 | return storage;
29 | }
30 |
31 | function getBase(): Array {
32 | const books = getBookStorage().all();
33 |
34 | return Array(10)
35 | .fill(undefined)
36 | .map((): Test => {
37 | const date = faker.date.anytime();
38 | date.setHours(0, 0, 0);
39 | date.setUTCSeconds(0, 0);
40 | return {
41 | id: faker.string.uuid(),
42 | text_field: faker.music.songName(),
43 | textarea_field: faker.lorem.lines(2),
44 | checkbox_field: faker.datatype.boolean(),
45 | number_field: faker.number.int({ min: -10000, max: 10000 }),
46 | toggle_field: faker.datatype.boolean(),
47 | url_field: faker.internet.url(),
48 | path_field: faker.system.filePath().replace(/^\/[^/]+\//g, '/'),
49 | date_field: date,
50 | key_value_object_field: fakeObject() as object,
51 | crud_entity_field:
52 | books[faker.number.int({ min: 0, max: books.length - 1 })].id ?? undefined,
53 | entities_array_field: Array(faker.number.int({ min: 2, max: 5 }))
54 | .fill(undefined)
55 | .map(() => books[faker.number.int({ min: 0, max: books.length - 1 })]),
56 | array_field: Array(faker.number.int({ min: 2, max: 10 }))
57 | .fill(undefined)
58 | .map(() => faker.person.zodiacSign())
59 | };
60 | });
61 | }
62 |
63 | function fakeObject(): object | string {
64 | return {
65 | data1: faker.lorem.words(faker.number.int({ min: 1, max: 4 })),
66 | data2: faker.lorem.words(faker.number.int({ min: 1, max: 4 }))
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/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/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/translations/en.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '$lib/i18n';
2 |
3 | const dictionary: Dictionary = {
4 | 'crud.header.edit': 'Edit',
5 | 'crud.header.new': 'New',
6 | 'crud.form.submit': 'Submit',
7 | 'crud.list.label': 'List of {name}',
8 | 'crud.delete.label': 'Delete {name}',
9 | 'crud.new.label': 'Create new {name}',
10 | 'crud.edit.label': 'Update {name}',
11 | 'crud.view.label': 'View {name}',
12 | 'crud.delete.are_you_sure': 'Are you sure you want to delete {name} with identifier "{id}"?',
13 | 'crud.delete.cancel': 'No, cancel',
14 | 'crud.delete.yes_delete': 'Yes, delete',
15 | 'data_table.items.unsupported_action': 'Action type "{action}" not supported.',
16 | 'data_table.reset_sorting': 'Reset sorting',
17 | 'error.crud.form.entity_field_list_fetch_error':
18 | 'An error occurred while fetching a list of elements:\n{message}',
19 | 'error.crud.form.entity_field_view_fetch_error':
20 | 'An error occurred while fetching an element:\n{message}',
21 | 'error.crud.form.object.duplicate_key': '⚠ Key already exists!',
22 | 'error.crud.could_not_find_crud_name': 'Could not find a CRUD config with name "{crud}".',
23 | 'error.crud.no_crud_specified': 'No CRUD name was specified when displaying the Dashboard.',
24 | 'error.crud.could_not_find_operation_name':
25 | 'Could not find a CRUD operation named "{operation}" for CRUD definition named "{crud}".',
26 | 'error.crud.could_not_find_component':
27 | 'No template found for operation "{operation}" and CRUD "{crud}".',
28 | 'error.crud.no_operation_specified':
29 | 'No CRUD operation was specified when displaying the "{crud}" CRUD.',
30 | 'error.crud.list.no_elements': 'No elements found.',
31 | 'error.crud.list.load_error': 'An error occured when loading data.',
32 | 'error.crud.entity.not_found': 'No element found with this identifier.',
33 | 'datatable.filters.menu_title': 'Filters',
34 | 'datatable.filters.submit_filters': 'Filter results',
35 | 'datatable.filters.reset_filters': 'Reset filters',
36 | 'filters.date.from': 'From this date',
37 | 'filters.date.to': 'Up to this date',
38 | 'carbon.theme.change_action': 'Change theme',
39 | 'carbon.theme.white': 'White',
40 | 'carbon.theme.g10': 'Light gray (g10)',
41 | 'carbon.theme.g80': 'Gray (g80)',
42 | 'carbon.theme.g90': 'Dark gray (g90)',
43 | 'carbon.theme.g100': 'Dark (g100)'
44 | };
45 |
46 | export default dictionary;
47 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentType } from 'svelte';
2 |
3 | /** */
4 | export type SubmitButtonType =
5 | | 'primary'
6 | | 'secondary'
7 | | 'tertiary'
8 | | 'ghost'
9 | | 'danger'
10 | | 'danger-tertiary'
11 | | 'danger-ghost';
12 |
13 | /** */
14 | export type ThemeConfig = {
15 | dashboard: ComponentType;
16 | dataTable: ComponentType;
17 | adminLayout: ComponentType;
18 | viewField: ComponentType;
19 | formField: ComponentType;
20 | form: ComponentType;
21 | crudActions: {
22 | view: ComponentType;
23 | new: ComponentType;
24 | list: ComponentType;
25 | edit: ComponentType;
26 | delete: ComponentType;
27 | [key: string]: ComponentType;
28 | };
29 | viewFields: {
30 | checkbox: ComponentType;
31 | column: ComponentType;
32 | crud_entity: ComponentType;
33 | date: ComponentType;
34 | default: ComponentType;
35 | label: ComponentType;
36 | number: ComponentType;
37 | key_value_object: ComponentType;
38 | tabs: ComponentType;
39 | textarea: ComponentType;
40 | text: ComponentType;
41 | toggle: ComponentType;
42 | url: ComponentType;
43 | array: ComponentType;
44 | email: ComponentType;
45 | [key: string]: ComponentType;
46 | };
47 | formFields: {
48 | checkbox: ComponentType;
49 | column: ComponentType;
50 | crud_entity: ComponentType;
51 | date: ComponentType;
52 | default: ComponentType;
53 | number: ComponentType;
54 | key_value_object: ComponentType;
55 | tabs: ComponentType;
56 | textarea: ComponentType;
57 | text: ComponentType;
58 | toggle: ComponentType;
59 | url: ComponentType;
60 | array: ComponentType;
61 | email: ComponentType;
62 | [key: string]: ComponentType;
63 | };
64 | filters: {
65 | boolean: ComponentType;
66 | text: ComponentType;
67 | date_range: ComponentType;
68 | numeric: ComponentType;
69 | [key: string]: ComponentType;
70 | };
71 | menu: {
72 | sideMenu: ComponentType;
73 | topLeftMenu: ComponentType;
74 | topMenu: ComponentType;
75 | topRightMenu: ComponentType;
76 | };
77 | };
78 |
79 | /** */ export type CrudTheme = keyof ThemeConfig['crudActions'];
80 |
81 | /** */ export type ViewFieldTheme = keyof ThemeConfig['viewFields'];
82 |
83 | /** */ export type FormFieldTheme = keyof ThemeConfig['formFields'];
84 |
85 | /** */ export type FilterTheme = keyof ThemeConfig['filters'];
86 |
87 | /** */ export type MenuTheme = keyof ThemeConfig['menu'];
88 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/KeyValueObjectField.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 './KeyValueObjectField.svelte';
6 | import { KeyValueObjectField } from '$lib';
7 |
8 | describe(
9 | 'KeyValueObjectField component',
10 | () => {
11 | it('displays error with undefined', async () => {
12 | const rendered = render(ComponentToTest, {
13 | // @ts-ignore
14 | value: undefined,
15 | field: new KeyValueObjectField('', '', '', {})
16 | });
17 |
18 | const element = rendered.container;
19 | expect(element).toBeDefined();
20 | const child = element.querySelector('span');
21 | expect(child).toBeDefined();
22 | expect(child?.innerHTML).toStrictEqual('No value');
23 | });
24 |
25 | it('displays error with empty object', async () => {
26 | const rendered = render(ComponentToTest, {
27 | value: {},
28 | field: new KeyValueObjectField('', '', '', {})
29 | });
30 |
31 | const element = rendered.container;
32 | expect(element).toBeDefined();
33 | const child = element.querySelector('span');
34 | expect(child).toBeDefined();
35 | expect(child?.innerHTML).toStrictEqual('Not found');
36 | });
37 |
38 | it('displays value with object and single-depth property tree', async () => {
39 | const rendered = render(ComponentToTest, {
40 | value: { some_value: 'Found!' },
41 | field: new KeyValueObjectField('', '', 'some_value', {})
42 | });
43 |
44 | const element = rendered.container;
45 | expect(element).toBeDefined();
46 | const child = element.querySelector('span');
47 | expect(child).toBeDefined();
48 | expect(child?.innerHTML).toStrictEqual('Found!');
49 | });
50 |
51 | it('displays value with object and more-depth property tree', async () => {
52 | const rendered = render(ComponentToTest, {
53 | value: { level1: { level2: { level3: { level4: 'Found!' } } } },
54 | field: new KeyValueObjectField('', '', 'level1.level2.level3.level4', {})
55 | });
56 |
57 | const element = rendered.container;
58 | expect(element).toBeDefined();
59 | const child = element.querySelector('span');
60 | expect(child).toBeDefined();
61 | expect(child?.innerHTML).toStrictEqual('Found!');
62 | });
63 | },
64 | testOptions
65 | );
66 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/Menu/TopLeftMenu.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 | {#each links as link}
17 | {#if link instanceof Submenu}
18 |
19 | {#each link.links as subLink}
20 | {#if subLink instanceof Divider}
21 |
22 | {:else if subLink instanceof UrlAction}
23 |
28 | {subLink.label ? $_(subLink.label) : ''}
29 |
30 | {:else if subLink instanceof CallbackAction}
31 | subLink.call()}
34 | {...link.options.htmlAttributes}
35 | >
36 | {subLink.label ? $_(subLink.label) : ''}
37 |
38 | {:else}
39 |
40 | {subLink.label ? $_(subLink.label) : ''}
41 |
42 | {/if}
43 | {/each}
44 |
45 | {:else if link instanceof Divider}
46 |
47 | {:else if link instanceof UrlAction}
48 |
49 | {link.label ? $_(link.label) : ''}
50 |
51 | {:else if link instanceof CallbackAction}
52 | link.call()}
55 | {...link.options.htmlAttributes}
56 | >
57 | {link.label ? $_(link.label) : ''}
58 |
59 | {:else}
60 |
61 | {link.label ? $_(link.label) : ''}
62 |
63 | {/if}
64 | {/each}
65 |
66 |
--------------------------------------------------------------------------------
/src/lib/translations/fr.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '$lib/i18n';
2 |
3 | const dictionary: Dictionary = {
4 | 'crud.header.edit': 'Modifier',
5 | 'crud.header.new': 'Créer',
6 | 'crud.form.submit': 'Envoyer',
7 | 'crud.list.label': 'Liste des {name}',
8 | 'crud.delete.label': 'Supprimer {name}',
9 | 'crud.new.label': 'Créer {name}',
10 | 'crud.edit.label': 'Modifier {name}',
11 | 'crud.view.label': 'Voir {name}',
12 | 'crud.delete.are_you_sure': 'Voulez-vous vraiment supprimer le {name} à l\'identifiant "{id}" ?',
13 | 'crud.delete.cancel': 'Non, annuler',
14 | 'crud.delete.yes_delete': 'Oui, supprimer',
15 | 'data_table.items.unsupported_action': 'Type d\'action "{operation}" non prise en charge.',
16 | 'data_table.reset_sorting': 'Réinitialiser le tri',
17 | 'error.crud.form.entity_field_list_fetch_error':
18 | "Une erreur est survenue lors de la récupération d'une liste d'élements:\n{message}",
19 | 'error.crud.form.entity_field_view_fetch_error':
20 | "Une erreur est survenue lors de la récupération d'un élement:\n{message}",
21 | 'error.crud.form.object.duplicate_key': '⚠ Clé déjà présente !',
22 | 'error.crud.could_not_find_crud_name': 'Configuration de CRUD "{crud}" introuvable.',
23 | 'error.crud.no_crud_specified':
24 | "Aucun nom de CRUD n'a été spécifié pour afficher le tableau de bord.",
25 | 'error.crud.could_not_find_operation_name':
26 | 'Opération "{operation}" introuvable pour la configuration de CRUD "{crud}".',
27 | 'error.crud.could_not_find_component':
28 | 'Aucun template trouvé pour l\'opération "{operation}" et le CRUD "{crud}".',
29 | 'error.crud.no_operation_specified':
30 | 'Aucune opération n\'a été spécifié pour afficher le CRUD "{crud}".',
31 | 'error.crud.list.no_elements': 'Aucun élément trouvé.',
32 | 'error.crud.list.load_error': 'Une erreur est survenue au chargement des données.',
33 | 'error.crud.entity.not_found': 'Aucun élément trouvé avec cet identifiant.',
34 | 'datatable.filters.menu_title': 'Filtres',
35 | 'datatable.filters.submit_filters': 'Filtrer les résultats',
36 | 'datatable.filters.reset_filters': 'Réinitialiser les filtres',
37 | 'filters.date.from': 'À partir de cette date',
38 | 'filters.date.to': "Jusqu'à cette date",
39 | 'carbon.theme.change_action': 'Changer le thème',
40 | 'carbon.theme.white': 'Blanc',
41 | 'carbon.theme.g10': 'Gris clair (g10)',
42 | 'carbon.theme.g80': 'Gris (g80)',
43 | 'carbon.theme.g90': 'Gris foncé (g90)',
44 | 'carbon.theme.g100': 'Sombre (g100)'
45 | };
46 |
47 | export default dictionary;
48 |
--------------------------------------------------------------------------------
/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 |
52 | {#each values as itemValue}
53 | {@const val =
54 | itemValue[field.options.list_provider_operation.value_field ?? 'id'] ?? undefined}
55 | {@const txt = itemValue[field.options.list_provider_operation.label_field] ?? val}
56 | {#if val && txt}
57 |
58 | {/if}
59 | {/each}
60 |
61 | {:catch error}
62 |
63 | {$_('error.crud.form.entity_field_list_fetch_error', { values: { message: error.message } })}
64 |
65 | {/await}
66 | {/if}
67 |
--------------------------------------------------------------------------------
/src/testApp/Dashboard.ts:
--------------------------------------------------------------------------------
1 | import Book from 'carbon-icons-svelte/lib/Book.svelte';
2 | import Document from 'carbon-icons-svelte/lib/Document.svelte';
3 | import Home from 'carbon-icons-svelte/lib/Home.svelte';
4 | import Menu from 'carbon-icons-svelte/lib/Menu.svelte';
5 | import Switcher from 'carbon-icons-svelte/lib/Switcher.svelte';
6 | import User from 'carbon-icons-svelte/lib/User.svelte';
7 |
8 | import { DashboardDefinition, CallbackAction, UrlAction, Submenu } from '$lib';
9 | import { carbon } from '$lib/themes/svelte';
10 |
11 | import fr from './translations/fr';
12 | import { bookCrud } from './BookCrud';
13 | import { authorCrud } from './AuthorCrud';
14 | import { testCrud } from './TestCrud';
15 | import { ThemeChangerAction } from '$lib/themes/svelte/carbon';
16 |
17 | let newLinkIndex = 1;
18 |
19 | const dynamicallyCustomizable: Submenu = new Submenu('Dynamic menu', Switcher, [
20 | new CallbackAction('Add a new link to the menu', null, () => {
21 | dashboard.stores.topRightMenu.update((links) => {
22 | dynamicallyCustomizable.links.push(new UrlAction('Custom link' + newLinkIndex++, '/'));
23 | return [...links];
24 | });
25 | })
26 | ]);
27 |
28 | export const dashboard = new DashboardDefinition({
29 | theme: carbon,
30 | adminConfig: {
31 | defaultLocale: 'en',
32 | autoCloseSideMenu: false,
33 | rootUrl: '/admin',
34 | head: {
35 | brandName: 'Svelte Admin',
36 | appName: 'Demo'
37 | }
38 | },
39 | sideMenu: [
40 | new UrlAction('Homepage', '/', Home),
41 | new UrlAction('Books', '/admin/books/list', Book),
42 | new UrlAction('Authors', '/admin/authors/list', User),
43 | new CallbackAction('Callback link', null, () => {
44 | alert('Hey, this link is called with Javascript!');
45 | }),
46 | new Submenu('Submenu', null, [
47 | new UrlAction('Submenu 1', '#', Book),
48 | new UrlAction('Submenu 2', '#', Book)
49 | ])
50 | ],
51 | topLeftMenu: [
52 | new ThemeChangerAction(),
53 | new UrlAction('API Docs', '/apidocs', Document, { htmlAttributes: { rel: 'external' } })
54 | ],
55 | topRightMenu: [
56 | new UrlAction('Single menu link', '/'),
57 | new CallbackAction('Single menu callback', null, () => {
58 | alert('Hey, this link is called with Javascript too!');
59 | }),
60 |
61 | new Submenu('Specific submenu', Menu, [
62 | new UrlAction('Submenu item 1', '/', Book),
63 | new UrlAction('Submenu item 2', '/', Book)
64 | ]),
65 | dynamicallyCustomizable
66 | ],
67 | localeDictionaries: {
68 | fr: fr
69 | },
70 | cruds: [bookCrud, authorCrud, testCrud]
71 | });
72 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/Crud/CrudForm.svelte:
--------------------------------------------------------------------------------
1 |
46 |
47 |
74 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/FilterComponents/BooleanFilter.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
43 |
44 | (value = true)}
48 | size="small"
49 | kind="tertiary"
50 | style={buttonStyle(value, true, '#0a0')}
51 | >
52 |
53 |
54 |
55 | (value = false)}
59 | size="small"
60 | kind="tertiary"
61 | style={buttonStyle(value, false, '#a00')}
62 | >
63 |
64 |
65 |
66 | (value = null)}
71 | size="small"
72 | kind="tertiary"
73 | style={buttonStyle(value, null, '#333')}
74 | >
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/themes/svelte/carbon/Menu/SideMenu.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 | {#each links as link}
27 | {#if link instanceof Submenu}
28 |
29 | {#each link.links as subLink}
30 | {#if subLink instanceof Divider}
31 |
32 | {:else if subLink instanceof UrlAction}
33 |
34 |
35 | {subLink.label ? $_(subLink.label) : ''}
36 |
37 | {:else if subLink instanceof CallbackAction}
38 | subLink.call()} {...link.options.htmlAttributes}>
39 |
40 | {subLink.label ? $_(subLink.label) : ''}
41 |
42 | {:else}
43 |
44 |
45 | {subLink.label ? $_(subLink.label) : ''}
46 |
47 | {/if}
48 | {/each}
49 |
50 | {:else if link instanceof Divider}
51 |
52 | {:else if link instanceof UrlAction}
53 |
54 | {link.label ? $_(link.label) : ''}
55 |
56 | {:else if link instanceof CallbackAction}
57 | link.call()}
60 | {...link.options.htmlAttributes}
61 | >
62 | {link.label ? $_(link.label) : ''}
63 |
64 | {:else}
65 |
66 | {link.label ? $_(link.label) : ''}
67 |
68 | {/if}
69 | {/each}
70 |
71 |
72 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/ViewFieldsComponents/NumberField.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 './NumberField.svelte';
6 | import { faker } from '@faker-js/faker';
7 |
8 | describe(
9 | 'NumberField component',
10 | () => {
11 | it('displays error with undefined', async () => {
12 | const rendered = render(ComponentToTest, {
13 | value: undefined
14 | });
15 |
16 | const child = rendered.container.querySelector('span');
17 | expect(child).toBeDefined();
18 | expect(child?.innerHTML).toStrictEqual('No value');
19 | });
20 |
21 | it('displays error with empty string', async () => {
22 | const rendered = render(ComponentToTest, {
23 | value: ''
24 | });
25 |
26 | const child = rendered.container.querySelector('span');
27 | expect(child).toBeDefined();
28 | expect(child?.innerHTML).toStrictEqual('No value');
29 | });
30 |
31 | it.each([
32 | { value: 'Some value' },
33 | { value: {} },
34 | { value: { a: 'b' } },
35 | { value: [] },
36 | { value: [1, 2] },
37 | { value: ['a', 'b'] }
38 | ])('displays error with non-number value "%s"', async (value) => {
39 | const rendered = render(ComponentToTest, {
40 | value: value
41 | });
42 |
43 | const child = rendered.container.querySelector('span');
44 | expect(child).toBeDefined();
45 | expect(child?.innerHTML).toStrictEqual('NaN');
46 | });
47 |
48 | it.each(
49 | Array(5)
50 | .fill(0)
51 | .map(() => faker.number.int({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }))
52 | )('displays number with integer value "%d"', async (value) => {
53 | const rendered = render(ComponentToTest, {
54 | value: value
55 | });
56 |
57 | const child = rendered.container.querySelector('span');
58 | expect(child).toBeDefined();
59 | expect(child?.innerHTML).toStrictEqual(value.toString());
60 | });
61 |
62 | it.each(
63 | Array(5)
64 | .fill(0)
65 | .map(() => faker.number.float({ min: -50000, max: 50000, fractionDigits: 12 }))
66 | )('displays number with float value "%d"', async (value) => {
67 | const rendered = render(ComponentToTest, {
68 | value: value
69 | });
70 |
71 | const child = rendered.container.querySelector('span');
72 | expect(child).toBeDefined();
73 | expect(child?.innerHTML).toStrictEqual(value.toString());
74 | });
75 |
76 | it.each(
77 | Array(5)
78 | .fill(0)
79 | .map(() =>
80 | faker.number.bigInt({
81 | min: BigInt(Number.MIN_SAFE_INTEGER),
82 | max: BigInt(Number.MAX_SAFE_INTEGER)
83 | })
84 | )
85 | )('displays number with bigint value "%d"', async (value) => {
86 | const rendered = render(ComponentToTest, {
87 | value: value
88 | });
89 |
90 | const child = rendered.container.querySelector('span');
91 | expect(child).toBeDefined();
92 | expect(child?.innerHTML).toStrictEqual(value.toString().replace(/n$/gi, ''));
93 | });
94 | },
95 | testOptions
96 | );
97 |
--------------------------------------------------------------------------------
/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/testApp/internal/memoryStorage.ts:
--------------------------------------------------------------------------------
1 | type Entity = object & { id: string | number };
2 |
3 | export interface InternalStorage {
4 | all(): Array;
5 | get(id: string | number): T;
6 | add(object: T): void;
7 | remove(id: string | number): void;
8 | update(object: T): void;
9 | }
10 |
11 | export class InMemoryStorage implements InternalStorage {
12 | public readonly objectName: string;
13 | public readonly localStorageName: string;
14 | public readonly baseInitializer: () => Array;
15 | private items: Array = [];
16 |
17 | constructor(objectName: string, baseInitializer: () => Array) {
18 | this.objectName = objectName;
19 | this.baseInitializer = baseInitializer;
20 |
21 | this.localStorageName = 'svelte-admin-dev-' + this.objectName.replace(/s$/g, '').toLowerCase();
22 | }
23 |
24 | public get(id: string | number): T {
25 | const found = this.all().filter((b) => b.id.toString() === id.toString());
26 |
27 | if (found.length === 0) {
28 | throw new Error(`Object of type "${this.objectName}" with id "${id}" was not found.`);
29 | }
30 |
31 | if (found.length !== 1) {
32 | throw new Error(
33 | `Error: Found multiple objects of type "${this.objectName}" with id "${id}".`
34 | );
35 | }
36 |
37 | return found[0];
38 | }
39 |
40 | public remove(id: string | number): void {
41 | const item = this.get(id);
42 |
43 | this.saveList(this.all().filter((b) => b.id.toString() !== item.id.toString()));
44 | }
45 |
46 | public add(object: T): void {
47 | let item: T | null = null;
48 | try {
49 | item = this.get(object.id);
50 | } catch (e) {
51 | console.error('Could not fetch item from storage: ');
52 | console.error(e);
53 | }
54 | if (item) {
55 | throw new Error(
56 | `Attempted to create new object of type "${this.objectName}", but its ID was already found. Did you mean to use "update" instead?`
57 | );
58 | }
59 |
60 | this.saveList([...this.all(), object]);
61 | }
62 |
63 | public update(object: T): void {
64 | const item = this.get(object.id);
65 |
66 | this.saveList(this.all().map((i) => (i.id === item.id ? item : i)));
67 | }
68 |
69 | public all(): Array {
70 | if (this.items.length) {
71 | return this.items;
72 | }
73 | if (typeof window === 'undefined') {
74 | return [];
75 | }
76 |
77 | let memory = window.localStorage.getItem(this.localStorageName);
78 | if (memory === null || memory === undefined || memory === '') {
79 | memory = JSON.stringify(this.baseInitializer(), this.serializeReplacer);
80 | window.localStorage.setItem(this.localStorageName, memory);
81 | }
82 |
83 | this.items = JSON.parse(memory);
84 |
85 | return this.items;
86 | }
87 |
88 | private saveList(newList: T[]) {
89 | const serialized = JSON.stringify(newList, this.serializeReplacer);
90 | window.localStorage.setItem(this.localStorageName, serialized);
91 | }
92 |
93 | private serializeReplacer(this: T, key: string, value: unknown): unknown {
94 | if (key === '__crud_operation') {
95 | return undefined;
96 | }
97 |
98 | return value;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/lib/themes/svelte/carbon/Menu/TopRightMenu.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 | {#each links as link (link)}
23 | {#if link instanceof Submenu}
24 | links.filter((s) => s !== link).forEach((s) => (s.options.isOpen = false))}
29 | >
30 |
31 | {#each link.links as subLink}
32 | {#if subLink instanceof Divider}
33 |
34 | {:else if subLink instanceof UrlAction}
35 |
36 |
37 | {subLink.label ? $_(subLink.label) : ''}
38 |
39 | {:else if subLink instanceof CallbackAction}
40 | subLink.call()} {...link.options.htmlAttributes}>
41 |
42 | {subLink.label ? $_(subLink.label) : ''}
43 |
44 | {:else}
45 |
46 | {subLink.label ? $_(subLink.label) : ''}
47 |
48 | {/if}
49 | {/each}
50 |
51 |
52 | {:else if link instanceof Divider}
53 | {link.label ? $_(link.label) : ''}
54 | {:else if link instanceof UrlAction}
55 |
61 | {:else if link instanceof CallbackAction}
62 | link.call()}
66 | {...link.options.htmlAttributes}
67 | >
68 |
69 |
70 | {:else}
71 |
76 | {/if}
77 | {/each}
78 |
79 |
--------------------------------------------------------------------------------
/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 |
90 |
91 |
92 | {/if}
93 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
60 | {i}
61 |
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 | removeKey(i)}>
91 |
92 |
93 |
94 | {/if}
95 |
96 | {/each}
97 | {#if !field.options.disabled}
98 |
99 | addKey()}>
100 |
101 |
102 |
103 | {/if}
104 |
105 |
106 |
--------------------------------------------------------------------------------
/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/themes/svelte/carbon/index.ts:
--------------------------------------------------------------------------------
1 | import CrudDelete from './Crud/CrudDelete.svelte';
2 | import CrudEdit from './Crud/CrudEdit.svelte';
3 | import CrudForm from './Crud/CrudForm.svelte';
4 | import CrudFormField from './Crud/CrudFormField.svelte';
5 | import CrudList from './Crud/CrudList.svelte';
6 | import CrudNew from './Crud/CrudNew.svelte';
7 | import CrudView from './Crud/CrudView.svelte';
8 | import CrudViewField from './Crud/CrudViewField.svelte';
9 |
10 | import Dashboard from './Dashboard/Dashboard.svelte';
11 |
12 | import DataTable from './DataTable/DataTable.svelte';
13 |
14 | import CheckboxFormField from './FormFieldsComponents/CheckboxField.svelte';
15 | import ColumnsFormField from './FormFieldsComponents/ColumnsField.svelte';
16 | import CrudEntityFormField from './FormFieldsComponents/CrudEntityField.svelte';
17 | import DateFormField from './FormFieldsComponents/DateField.svelte';
18 | import DefaultFormField from './FormFieldsComponents/DefaultField.svelte';
19 | import NumberFormField from './FormFieldsComponents/NumberField.svelte';
20 | import KeyValueObjectFormField from './FormFieldsComponents/KeyValueObjectField.svelte';
21 | import TabsFormField from './FormFieldsComponents/TabsField.svelte';
22 | import TextareaFormField from './FormFieldsComponents/TextareaField.svelte';
23 | import TextFormField from './FormFieldsComponents/TextField.svelte';
24 | import ToggleFormField from './FormFieldsComponents/ToggleField.svelte';
25 | import UrlFormField from './FormFieldsComponents/UrlField.svelte';
26 | import ArrayFormField from './FormFieldsComponents/ArrayField.svelte';
27 | import EmailFormField from './FormFieldsComponents/EmailField.svelte';
28 |
29 | import AdminLayout from './Layout/AdminLayout.svelte';
30 |
31 | import SideMenu from './Menu/SideMenu.svelte';
32 | import TopLeftMenu from './Menu/TopLeftMenu.svelte';
33 | import TopMenu from './Menu/TopMenu.svelte';
34 | import TopRightMenu from './Menu/TopRightMenu.svelte';
35 |
36 | import CheckboxViewField from './ViewFieldsComponents/CheckboxField.svelte';
37 | import ColumnsViewField from './ViewFieldsComponents/ColumnsField.svelte';
38 | import CrudEntityViewField from './ViewFieldsComponents/CrudEntityField.svelte';
39 | import DateViewField from './ViewFieldsComponents/DateField.svelte';
40 | import DefaultViewField from './ViewFieldsComponents/DefaultField.svelte';
41 | import NumberViewField from './ViewFieldsComponents/NumberField.svelte';
42 | import KeyValueObjectViewField from './ViewFieldsComponents/KeyValueObjectField.svelte';
43 | import TabsViewField from './ViewFieldsComponents/TabsField.svelte';
44 | import TextareaViewField from './ViewFieldsComponents/DefaultField.svelte';
45 | import TextViewField from './ViewFieldsComponents/DefaultField.svelte';
46 | import ToggleViewField from './ViewFieldsComponents/ToggleField.svelte';
47 | import UrlViewField from './ViewFieldsComponents/UrlField.svelte';
48 | import ArrayViewField from './ViewFieldsComponents/ArrayField.svelte';
49 | import EmailViewField from './ViewFieldsComponents/EmailField.svelte';
50 |
51 | import ViewLabel from './ViewFieldsComponents/ViewLabel.svelte';
52 |
53 | import BooleanFilter from './FilterComponents/BooleanFilter.svelte';
54 | import DateRangeFilter from './FilterComponents/DateRangeFilter.svelte';
55 | import NumericFilter from './FilterComponents/NumericFilter.svelte';
56 | import TextFilter from './FilterComponents/TextFilter.svelte';
57 |
58 | import type { ThemeConfig } from '$lib';
59 |
60 | const theme: ThemeConfig = {
61 | adminLayout: AdminLayout,
62 | dashboard: Dashboard,
63 | dataTable: DataTable,
64 | viewField: CrudViewField,
65 | formField: CrudFormField,
66 | form: CrudForm,
67 | crudActions: {
68 | delete: CrudDelete,
69 | edit: CrudEdit,
70 | list: CrudList,
71 | new: CrudNew,
72 | view: CrudView
73 | },
74 | viewFields: {
75 | checkbox: CheckboxViewField,
76 | column: ColumnsViewField,
77 | crud_entity: CrudEntityViewField,
78 | date: DateViewField,
79 | default: DefaultViewField,
80 | label: ViewLabel,
81 | number: NumberViewField,
82 | key_value_object: KeyValueObjectViewField,
83 | tabs: TabsViewField,
84 | text: TextViewField,
85 | textarea: TextareaViewField,
86 | toggle: ToggleViewField,
87 | url: UrlViewField,
88 | array: ArrayViewField,
89 | email: EmailViewField
90 | },
91 | formFields: {
92 | checkbox: CheckboxFormField,
93 | column: ColumnsFormField,
94 | crud_entity: CrudEntityFormField,
95 | date: DateFormField,
96 | default: DefaultFormField,
97 | number: NumberFormField,
98 | key_value_object: KeyValueObjectFormField,
99 | tabs: TabsFormField,
100 | text: TextFormField,
101 | textarea: TextareaFormField,
102 | toggle: ToggleFormField,
103 | url: UrlFormField,
104 | array: ArrayFormField,
105 | email: EmailFormField
106 | },
107 | filters: {
108 | boolean: BooleanFilter,
109 | date_range: DateRangeFilter,
110 | numeric: NumericFilter,
111 | text: TextFilter
112 | },
113 | menu: {
114 | sideMenu: SideMenu,
115 | topLeftMenu: TopLeftMenu,
116 | topMenu: TopMenu,
117 | topRightMenu: TopRightMenu
118 | }
119 | };
120 |
121 | export default theme;
122 |
123 | export { default as ThemeChangerAction } from './lib/ThemeChangeMenu';
124 |
--------------------------------------------------------------------------------
/src/testApp/AuthorCrud.ts:
--------------------------------------------------------------------------------
1 | // src/lib/AuthorCrud.ts
2 | import {
3 | CallbackAction,
4 | CallbackStateProcessor,
5 | CallbackStateProvider,
6 | CrudDefinition,
7 | Delete,
8 | Edit,
9 | List,
10 | New,
11 | PaginatedResults,
12 | TextareaField,
13 | TextField,
14 | TextFilter,
15 | type RequestParameters,
16 | UrlAction,
17 | View
18 | } from '$lib';
19 |
20 | import { faker } from '@faker-js/faker';
21 |
22 | import Pen from 'carbon-icons-svelte/lib/Pen.svelte';
23 | import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte';
24 | import ViewIcon from 'carbon-icons-svelte/lib/View.svelte';
25 | import { type Author, getStorage } from './internal/authorsInternal';
26 |
27 | const fields = [
28 | new TextField('first_name', 'First name'),
29 | new TextField('last_name', 'Last name'),
30 | new TextareaField('Bio', 'Biography')
31 | ];
32 |
33 | const IdField = new TextField('id', 'ID');
34 |
35 | const itemsPerPage = 10;
36 |
37 | function randomWait(maxMilliseconds: number) {
38 | return new Promise((resolve: (...args: unknown[]) => unknown) =>
39 | setTimeout(resolve, Math.random() * maxMilliseconds)
40 | );
41 | }
42 |
43 | export const authorCrud = new CrudDefinition({
44 | name: 'authors',
45 | label: { singular: 'Author', plural: 'Authors' },
46 | minStateLoadingTimeMs: 400,
47 |
48 | operations: [
49 | new List(
50 | [IdField, ...fields],
51 | [
52 | new UrlAction('View', '/admin/authors/view', ViewIcon),
53 | new UrlAction('Edit', '/admin/authors/edit', Pen),
54 | new UrlAction('Delete', '/admin/authors/delete', TrashCan)
55 | ],
56 | {
57 | globalActions: [
58 | new CallbackAction(
59 | 'Reset memory data',
60 | TrashCan,
61 | () => {
62 | window.localStorage.removeItem('authors');
63 | window.location.reload();
64 | },
65 | { buttonKind: 'ghost' }
66 | ),
67 | new UrlAction('New', '/admin/authors/new', Pen)
68 | ],
69 | pagination: {
70 | enabled: true,
71 | itemsPerPage: itemsPerPage
72 | },
73 | filters: [
74 | new TextFilter('first_name', 'First name contains'),
75 | new TextFilter('last_name', 'Last name contains'),
76 | new TextFilter('bio', 'Biography contains')
77 | ]
78 | }
79 | ),
80 | new View([IdField, ...fields]),
81 | new New(fields),
82 | new Edit(fields),
83 | new Delete(fields, new UrlAction('List', '/admin/authors/list'))
84 | ],
85 |
86 | stateProcessor: new CallbackStateProcessor(function (
87 | data,
88 | operation,
89 | requestParameters: RequestParameters = {}
90 | ) {
91 | if (operation.name === 'delete') {
92 | const id = (requestParameters.id || '').toString();
93 | getStorage().remove(id);
94 |
95 | return Promise.resolve();
96 | }
97 |
98 | if (operation.name === 'edit' || operation.name === 'new') {
99 | const id =
100 | operation.name === 'edit' ? (requestParameters.id || '').toString() : faker.string.uuid();
101 | const entity = data as Author;
102 | entity.id = id;
103 |
104 | if (operation.name === 'new') {
105 | getStorage().add(entity);
106 | } else {
107 | getStorage().update(entity);
108 | }
109 |
110 | return Promise.resolve();
111 | }
112 |
113 | console.error(
114 | 'StateProcessor error: Unsupported Authors Crud action "' + operation.name + '".'
115 | );
116 |
117 | return Promise.resolve();
118 | }),
119 |
120 | stateProvider: new CallbackStateProvider(async function (
121 | operation,
122 | requestParameters: RequestParameters = {}
123 | ) {
124 | console.info('Authors provider called', { operation: operation.name, requestParameters });
125 |
126 | if (operation.name === 'list') {
127 | const page = parseInt((requestParameters.page || '1').toString());
128 | if (isNaN(page)) {
129 | throw new Error(`Invalid "page" value: expected a number, got "${page}".`);
130 | }
131 |
132 | let entities = getStorage().all();
133 | const filters = requestParameters.filters;
134 | if (filters) {
135 | entities = entities.filter((entity) => {
136 | if (
137 | filters.first_name &&
138 | !entity.first_name.match(new RegExp(filters.first_name.toString(), 'gi'))
139 | ) {
140 | return false;
141 | }
142 | if (
143 | filters.last_name &&
144 | !entity.last_name.match(new RegExp(filters.last_name.toString(), 'gi'))
145 | ) {
146 | return false;
147 | }
148 | if (filters.bio && !entity.bio.match(new RegExp(filters.bio.toString(), 'gi'))) {
149 | return false;
150 | }
151 |
152 | return true;
153 | });
154 | }
155 |
156 | const listEntities = entities.slice(itemsPerPage * (page - 1), itemsPerPage * page);
157 |
158 | await randomWait(500);
159 |
160 | return new PaginatedResults(
161 | page,
162 | Math.ceil(entities.length / itemsPerPage),
163 | entities.length,
164 | listEntities
165 | );
166 | }
167 |
168 | if (operation.name === 'edit' || operation.name === 'view') {
169 | return Promise.resolve(getStorage().get((requestParameters?.id || '').toString()));
170 | }
171 |
172 | if (operation.name === 'entity_view') {
173 | return Promise.resolve(getStorage().get((requestParameters?.field_value || '').toString()));
174 | }
175 |
176 | if (operation.name === 'entity_list') {
177 | return Promise.resolve(getStorage().all());
178 | }
179 |
180 | console.error('StateProvider error: Unsupported Authors Crud action "' + operation.name + '".');
181 |
182 | return Promise.resolve(null);
183 | })
184 | });
185 |
--------------------------------------------------------------------------------
/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/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/testApp/BookCrud.ts:
--------------------------------------------------------------------------------
1 | // src/lib/BookCrud.ts
2 | import {
3 | CallbackAction,
4 | CallbackStateProcessor,
5 | CallbackStateProvider,
6 | CrudDefinition,
7 | DateField,
8 | DateRangeFilter,
9 | Delete,
10 | Edit,
11 | List,
12 | New,
13 | PaginatedResults,
14 | TextareaField,
15 | TextField,
16 | TextFilter,
17 | type RequestParameters,
18 | UrlAction,
19 | View
20 | } from '$lib';
21 |
22 | import { faker } from '@faker-js/faker';
23 |
24 | import Pen from 'carbon-icons-svelte/lib/Pen.svelte';
25 | import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte';
26 | import ViewIcon from 'carbon-icons-svelte/lib/View.svelte';
27 | import { type Book, getStorage } from './internal/booksInternal';
28 |
29 | const fields = [
30 | new TextField('title', 'Title', { placeholder: "Enter the book's title", sortable: true }),
31 | new TextareaField('description', 'Description', {
32 | placeholder: "Enter the book's descrption",
33 | help: "Please don't make a summary of the book, remember to not spoil your readers!"
34 | }),
35 | new DateField('publishedAt', 'Published at', { sortable: true })
36 | ];
37 |
38 | const IdField = new TextField('id', 'ID');
39 |
40 | const itemsPerPage = 10;
41 |
42 | function randomWait(maxMilliseconds: number) {
43 | return new Promise((resolve: (...args: unknown[]) => unknown) =>
44 | setTimeout(resolve, Math.random() * maxMilliseconds)
45 | );
46 | }
47 |
48 | export const bookCrud = new CrudDefinition({
49 | name: 'books',
50 | label: { singular: 'Book', plural: 'Books' },
51 | minStateLoadingTimeMs: 400,
52 |
53 | operations: [
54 | new List(
55 | [IdField, ...fields],
56 | [
57 | new UrlAction('View', '/admin/books/view', ViewIcon),
58 | new UrlAction('Edit', '/admin/books/edit', Pen),
59 | new UrlAction('Delete', '/admin/books/delete', TrashCan)
60 | ],
61 | {
62 | globalActions: [
63 | new CallbackAction(
64 | 'Reset memory data',
65 | TrashCan,
66 | () => {
67 | window.localStorage.removeItem('books');
68 | window.location.reload();
69 | },
70 | { buttonKind: 'ghost' }
71 | ),
72 | new UrlAction('New', '/admin/books/new', Pen)
73 | ],
74 | pagination: {
75 | enabled: true,
76 | itemsPerPage: itemsPerPage
77 | },
78 | filters: [
79 | new TextFilter('title', 'Title contains'),
80 | new TextFilter('description', 'Description contains'),
81 | new DateRangeFilter('publishedAt', 'Publication between')
82 | ]
83 | }
84 | ),
85 | new View([IdField, ...fields]),
86 | new New(fields),
87 | new Edit(fields),
88 | new Delete(fields, new UrlAction('List', '/admin/books/list'))
89 | ],
90 |
91 | stateProcessor: new CallbackStateProcessor(function (
92 | data,
93 | operation,
94 | requestParameters: RequestParameters = {}
95 | ) {
96 | if (operation.name === 'delete') {
97 | const id = (requestParameters.id || '').toString();
98 | getStorage().remove(id);
99 |
100 | return Promise.resolve();
101 | }
102 |
103 | if (operation.name === 'edit' || operation.name === 'new') {
104 | const id =
105 | operation.name === 'edit' ? (requestParameters.id || '').toString() : faker.string.uuid();
106 | const entity = data as Book;
107 | entity.id = id;
108 |
109 | if (operation.name === 'new') {
110 | getStorage().add(entity);
111 | } else {
112 | getStorage().update(entity);
113 | }
114 |
115 | return Promise.resolve();
116 | }
117 |
118 | console.error('StateProcessor error: Unsupported Books Crud action "' + operation.name + '".');
119 |
120 | return Promise.resolve();
121 | }),
122 |
123 | stateProvider: new CallbackStateProvider(async function (
124 | operation,
125 | requestParameters: RequestParameters = {}
126 | ) {
127 | console.info('Books provider called', { operation: operation.name, requestParameters });
128 |
129 | if (operation.name === 'list') {
130 | const page = parseInt((requestParameters.page || '1').toString());
131 | if (isNaN(page)) {
132 | throw new Error(`Invalid "page" value: expected a number, got "${page}".`);
133 | }
134 |
135 | let entities = getStorage().all();
136 | const filters = requestParameters.filters;
137 | if (filters) {
138 | entities = entities.filter((entity) => {
139 | if (filters.title && !entity.title.match(new RegExp(filters.title.toString(), 'gi'))) {
140 | return false;
141 | }
142 | if (
143 | filters.description &&
144 | !entity.description.match(new RegExp(filters.description.toString(), 'gi'))
145 | ) {
146 | return false;
147 | }
148 | if (filters.publishedAt) {
149 | const publishedAtFilter = filters.publishedAt as unknown as [
150 | string | undefined,
151 | string | undefined
152 | ];
153 | if (
154 | publishedAtFilter[0] &&
155 | new Date(entity.publishedAt) < new Date(publishedAtFilter[0])
156 | ) {
157 | return false;
158 | }
159 | if (
160 | publishedAtFilter[1] &&
161 | new Date(entity.publishedAt) > new Date(publishedAtFilter[1])
162 | ) {
163 | return false;
164 | }
165 | }
166 |
167 | return true;
168 | });
169 | }
170 |
171 | if (requestParameters.sort) {
172 | if (!requestParameters.sort?.title && !requestParameters.sort?.publishedAt) {
173 | console.warn('Sorting not supported for these sort parameters:');
174 | console.warn(requestParameters.sort);
175 | }
176 |
177 | // Apply sorting
178 | Object.entries(requestParameters.sort).forEach((sort) => {
179 | const field: keyof Book = sort[0] as keyof Book;
180 | const order: 'ASC' | 'DESC' = sort[1] as 'ASC' | 'DESC';
181 |
182 | entities = entities.sort(function (a: Book, b: Book) {
183 | const inverser = order === 'ASC' ? 1 : -1;
184 | return inverser * String(a[field]).localeCompare(String(b[field]));
185 | });
186 | });
187 | }
188 |
189 | const listEntities = entities.slice(itemsPerPage * (page - 1), itemsPerPage * page);
190 |
191 | await randomWait(500);
192 |
193 | return new PaginatedResults(
194 | page,
195 | Math.ceil(entities.length / itemsPerPage),
196 | entities.length,
197 | listEntities
198 | );
199 | }
200 |
201 | if (operation.name === 'edit' || operation.name === 'view') {
202 | return Promise.resolve(getStorage().get((requestParameters?.id || '').toString()));
203 | }
204 |
205 | if (operation.name === 'entity_view') {
206 | return Promise.resolve(getStorage().get((requestParameters?.field_value || '').toString()));
207 | }
208 |
209 | if (operation.name === 'entity_list') {
210 | return Promise.resolve(getStorage().all());
211 | }
212 |
213 | console.error('StateProvider error: Unsupported Books Crud action "' + operation.name + '".');
214 |
215 | return Promise.resolve(null);
216 | })
217 | });
218 |
--------------------------------------------------------------------------------