diff --git a/packages/common-helpers/src/form/input.ts b/packages/common-helpers/src/form/input.ts index c57c7638..d0250150 100644 --- a/packages/common-helpers/src/form/input.ts +++ b/packages/common-helpers/src/form/input.ts @@ -4,11 +4,12 @@ import isEqual from "lodash-es/isEqual"; import type { iFormInput, iFormInputDefault, + iFormOption, iFormValue, - iSelectOption, tFormAutocomplete, tFormIcon, tFormInputDefault, + tOptionsLoaderFn, } from "@open-xamu-co/ui-common-types"; import { eFormType, @@ -130,7 +131,7 @@ export class FormInput< implements iFormInput { // private - private _options: iSelectOption[]; + private _options: iFormOption[]; private _values: V[]; private _defaults?: [ iFormInputDefault, @@ -145,6 +146,7 @@ export class FormInput< // public readonly public readonly name: string; public readonly title?: string; + public readonly optionsFilter?: tOptionsLoaderFn; /** * Form input constructor @@ -163,46 +165,47 @@ export class FormInput< this.name = formInput.name; this.multiple = formInput.multiple ?? false; this.title = formInput.title; - this._options = formInput.options?.map(toOption) ?? []; + + // Initialize options array, skip if function + if (Array.isArray(formInput.options)) { + this._options = formInput.options.map(toOption); + this.optionsFilter = undefined; + } else { + this._options = []; + this.optionsFilter = formInput.options; + } + this._defaults = formInput.defaults; this.min = formInput.min ?? 1; this.meta = formInput.meta || {}; - // max cannot be lower than min or more than options if they exist + // Max cannot be lower than min or more than options if they exist const maxValue = this._options.length || formInput.max || 9e9; this.max = maxValue < this.min ? this.min : maxValue; this._values = formInput.values ||= []; if (isChoiceType(this.type)) { - // autoset single value if required - if (this.required && !this._values.length) { - const values = this.options.map(({ value }) => value); - - this._values = values.slice(0, Math.max(1, this.min)) as V[]; - } + // Autoset values if required + if (this.required && !this._values.length) this.autosetValues(); } else if (this.type !== eFormType.FILE) { - const length = Math.max(1, this.min); // negative values fallback + const length = Math.max(1, this.min); // Negative values fallback const values = Array(length).fill(getDefault(formInput.type, formInput.defaults)); - // use defaults + // Use defaults if (this._values.length < length) this._values = values; } } - get options(): iSelectOption[] { + get options(): iFormOption[] { return this._options; } - set options(updatedOptions: iSelectOption[] | undefined) { + set options(updatedOptions: iFormOption[] | undefined) { this._options = updatedOptions || []; if (isChoiceType(this.type)) { - // autoset single value if required - if (this.required && !this._values.length) { - const values = this.options.map(({ value }) => value); - - this._values = values.slice(0, Math.max(1, this.min)); - } + // Autoset values if required + if (this.required && !this._values.length) this.autosetValues(); } this.rerender(); @@ -217,12 +220,8 @@ export class FormInput< this._values = []; if (isChoiceType(this.type)) { - // autoset single value if required - if (this.required && !this._values.length) { - const values = this.options.map(({ value }) => value); - - this._values = values.slice(0, Math.max(1, this.min)); - } + // Autoset values if required + if (this.required && !this._values.length) this.autosetValues(); } else if (this.type !== eFormType.FILE) { const length = Math.max(1, this.min); // negative values fallback const values = Array(length).fill(getDefault(this.type, this.defaults)); @@ -246,6 +245,19 @@ export class FormInput< this.rerender(); } + /** Autoset values */ + private autosetValues() { + const autosetValuesArr = []; + + for (let i = 0; i < Math.max(1, this.min); i++) { + const option = this._options[i]; + + autosetValuesArr.push((option?.value || "") as V); + } + + this._values = autosetValuesArr as V[]; + } + /** * set rerender function * diff --git a/packages/common-helpers/src/format.ts b/packages/common-helpers/src/format.ts index 9a6172c0..bdfbd991 100644 --- a/packages/common-helpers/src/format.ts +++ b/packages/common-helpers/src/format.ts @@ -1,4 +1,11 @@ -import type { iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types"; +import type { iFormInputOptions, iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types"; + +/** + * Safe length for Vue keys / counts when `options` may be a static array or an async loader. + */ +export function getFormInputOptionsLength(options?: iFormInputOptions): number { + return Array.isArray(options) ? options.length : 0; +} /** * create iSelectOption or iFormOption from compatible values diff --git a/packages/common-helpers/src/i18n.ts b/packages/common-helpers/src/i18n.ts index 344ebfe1..a47c84d6 100644 --- a/packages/common-helpers/src/i18n.ts +++ b/packages/common-helpers/src/i18n.ts @@ -50,16 +50,16 @@ export default function useI18n -1 && plurals.length > 1) { + if (plurals.length > 1) { if (plurals.length === 2) { // product, products locale = plurals[count > 1 ? 1 : 0]; } else if (plurals.length === 3) { // no products, a product, products - locale = plurals[count ? (count > 1 ? 2 : 1) : 0]; + locale = plurals[count > 0 ? (count > 1 ? 2 : 1) : 0]; } } diff --git a/packages/common-types/src/form/class.ts b/packages/common-types/src/form/class.ts index e5daa554..0f5d0e5c 100644 --- a/packages/common-types/src/form/class.ts +++ b/packages/common-types/src/form/class.ts @@ -7,11 +7,12 @@ import type { import type { iFormInput, iFormInputDefault, + iFormOption, iFormValue, tFormAutocomplete, tFormIcon, + tOptionsLoaderFn, } from "./types"; -import type { iSelectOption } from "../values.js"; export declare abstract class tFormInputDefault< T extends eFormTypeBase | eFormTypeSimple | eFormTypeComplex = eFormTypeSimple, @@ -44,12 +45,13 @@ export declare abstract class tFormInput< // public readonly public readonly name: string; public readonly title?: string; - public readonly multiple: boolean; - public readonly min: number; - public readonly max: number; - public readonly meta?: Record; + public readonly optionsFilter?: tOptionsLoaderFn; // public - public options: iSelectOption[]; + public multiple: boolean; + public min: number; + public max: number; + public meta?: Record; + public options: iFormOption[]; public values: V[]; public defaults?: [ iFormInputDefault, diff --git a/packages/common-types/src/form/types.ts b/packages/common-types/src/form/types.ts index 776f0eea..f252466e 100644 --- a/packages/common-types/src/form/types.ts +++ b/packages/common-types/src/form/types.ts @@ -113,15 +113,29 @@ export interface iFormInputDefault< autocomplete?: tFormAutocomplete; } +export type tOptionsLoaderFn = + | ((v?: string | number, signal?: AbortSignal) => iFormOption[]) + | ((v?: string | number, signal?: AbortSignal) => Promise); + +/** If a function is given, it should be responsible of determining if v is a value or an alias (search) */ +export type iFormInputOptions = (string | number | iFormOption)[] | tOptionsLoaderFn; + /** * Complex input, sub input support + * Used as input for the classes */ export interface iFormInput< V extends iFormValue | iFormValue[], T extends eFormTypeBase | eFormTypeSimple | eFormTypeComplex, > extends iFormInputDefault { name: string; - options?: (string | number | iFormOption)[]; + /** + * Options for select, checkbox, radio inputs + * The usage of a function requires a compatible component (SelectFilter) + * + * @values array of options or function that returns options + */ + options?: iFormInputOptions; /** * An array of values to simplify validation * @@ -189,6 +203,13 @@ export interface iFetchResponse { [x: string]: any; } +/** + * Takes the parsed inputs values and perform a request (content creation, update, etc). + * + * @param values Values of the input + * @param fetcher Request function + * @returns Response with data and errors if any + */ export type tResponseFn = Record> = ( values: V ) => Promise>; diff --git a/packages/components-vue/src/components/base/Select.vue b/packages/components-vue/src/components/base/Select.vue index 3fb1df47..9d939d98 100644 --- a/packages/components-vue/src/components/base/Select.vue +++ b/packages/components-vue/src/components/base/Select.vue @@ -64,7 +64,8 @@ const { t } = useHelpers(useI18n); const selectOptions = computed(() => { - return (props.options ?? []).map(toOption); + // Only use array type, skip if function + return Array.isArray(props.options) ? props.options.map(toOption) : []; }); /** Prefer a predictable identifier */ const selectId = computed(() => { diff --git a/packages/components-vue/src/components/collapse/Simple.vue b/packages/components-vue/src/components/collapse/Simple.vue index 05b4953d..84fc0e7d 100644 --- a/packages/components-vue/src/components/collapse/Simple.vue +++ b/packages/components-vue/src/components/collapse/Simple.vue @@ -1,12 +1,12 @@ diff --git a/packages/components-vue/src/components/form/Simple.vue b/packages/components-vue/src/components/form/Simple.vue index 56c3241b..ff310d0c 100644 --- a/packages/components-vue/src/components/form/Simple.vue +++ b/packages/components-vue/src/components/form/Simple.vue @@ -29,7 +29,7 @@ {{ getSuggestedTitle(input) }}

(() => { - return (props.options || []).map(toOption).filter(({ hidden }) => !hidden); + // Only use array type, skip if function + if (!Array.isArray(props.options)) return []; + + return props.options.reduce((acc, current) => { + const option = toOption(current); + + if (!option.hidden) acc.push(option); + + return acc; + }, []); }); function choose(value: string | number) { diff --git a/packages/components-vue/src/components/select/Filter.stories.ts b/packages/components-vue/src/components/select/Filter.stories.ts index 724044c9..01f6f324 100644 --- a/packages/components-vue/src/components/select/Filter.stories.ts +++ b/packages/components-vue/src/components/select/Filter.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; import { ref } from "vue"; -import type { iSelectOption } from "@open-xamu-co/ui-common-types"; +import type { iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types"; import SelectFilter from "./Filter.vue"; @@ -36,4 +36,42 @@ export const WithOptions: Story = { }), }; +const allOptions: iFormOption[] = [ + { value: "alpha", alias: "Alpha" }, + { value: "beta", alias: "Beta" }, + { value: "betaLike", alias: "Beta Like" }, + { value: "gamma", alias: "Gamma" }, +]; + +/** Emulate async options loader */ +export async function mockOptionsLoader(query?: string | number): Promise { + await new Promise((r) => setTimeout(r, 200)); + + const q = String(query ?? "") + .trim() + .toLowerCase(); + + if (!q) return allOptions; + + return allOptions.filter( + (o) => + String(o.value).toLowerCase().includes(q) || + String(o.alias ?? o.value) + .toLowerCase() + .includes(q) + ); +} + +export const AsyncOptions: Story = { + render: (args) => ({ + components: { SelectFilter }, + setup() { + const model = ref("betaLike"); + + return { args, model, mockOptionsLoader }; + }, + template: '', + }), +}; + export default meta; diff --git a/packages/components-vue/src/components/select/Filter.vue b/packages/components-vue/src/components/select/Filter.vue index 152db056..1b2b5b38 100644 --- a/packages/components-vue/src/components/select/Filter.vue +++ b/packages/components-vue/src/components/select/Filter.vue @@ -1,7 +1,12 @@ diff --git a/packages/components-vue/src/composables/async.ts b/packages/components-vue/src/composables/async.ts index d32921f8..2bd1f3af 100644 --- a/packages/components-vue/src/composables/async.ts +++ b/packages/components-vue/src/composables/async.ts @@ -22,16 +22,16 @@ type tAsyncDataHandler = (nuxtApp?: any, options?: { signal?: AbortSignal }) * * @see https://nuxt.com/docs/api/composables/use-async-data#type */ -export function useAsyncDataFn( +export function useAsyncDataFn( handler: tAsyncDataHandler, options?: AsyncDataOptions ): iAsyncData; -export function useAsyncDataFn( +export function useAsyncDataFn( key: string, handler: tAsyncDataHandler, options?: AsyncDataOptions ): iAsyncData; -export function useAsyncDataFn( +export function useAsyncDataFn( handlerOrKey: string | tAsyncDataHandler, optionsOrHandler?: tAsyncDataHandler | AsyncDataOptions, options?: AsyncDataOptions diff --git a/packages/components-vue/src/types/props/base.ts b/packages/components-vue/src/types/props/base.ts index b75dca1a..9be2b228 100644 --- a/packages/components-vue/src/types/props/base.ts +++ b/packages/components-vue/src/types/props/base.ts @@ -1,5 +1,4 @@ import type { - iFormOption, tFormAutocomplete, tIndicative, tProp, @@ -8,6 +7,7 @@ import type { tThemeModifier, tThemeTuple, tSizeModifier, + iFormInputOptions, } from "@open-xamu-co/ui-common-types"; export interface iUseModifiersProps { @@ -115,7 +115,7 @@ export interface iInputProps extends iInputLikeProps { } export interface iSelectProps extends iInputLikeProps { - options?: Array; + options?: iFormInputOptions; /** * Multiple fields */ diff --git a/packages/components-vue/vite.config.d.ts b/packages/components-vue/vite.config.d.ts new file mode 100644 index 00000000..5980175a --- /dev/null +++ b/packages/components-vue/vite.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("vite").UserConfig; + +export default _default; diff --git a/packages/components-vue/vite.config.js b/packages/components-vue/vite.config.js new file mode 100644 index 00000000..a9743787 --- /dev/null +++ b/packages/components-vue/vite.config.js @@ -0,0 +1,42 @@ +import path from "node:path"; +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { dedupe: ["vue"] }, + build: { + lib: { + entry: { + // root + index: path.resolve(__dirname, "./src"), + // plugin + plugin: path.resolve(__dirname, "./src/plugin"), + // theme + theme: path.resolve(__dirname, "./src/composables/theme"), + }, + name: "@open-xamu-co/ui-components-vue", + formats: ["es", "cjs"], + }, + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled into your library + external: [ + "vue", + "lodash-es", + "sweetalert2", + "ts-md5", + "validator", + "vue-color", + "@open-xamu-co/ui-common-enums", + "@open-xamu-co/ui-common-helpers", + ], + output: { + // Provide global variables to use in the UMD build for externalized deps + globals: { vue: "Vue" }, + }, + preserveEntrySignatures: "strict", + treeshake: { moduleSideEffects: false }, + }, + }, +}); diff --git a/packages/components-vue/vitest.config.d.ts b/packages/components-vue/vitest.config.d.ts new file mode 100644 index 00000000..21c65ce9 --- /dev/null +++ b/packages/components-vue/vitest.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: Record; + +export default _default; diff --git a/packages/components-vue/vitest.config.js b/packages/components-vue/vitest.config.js new file mode 100644 index 00000000..f9154f8d --- /dev/null +++ b/packages/components-vue/vitest.config.js @@ -0,0 +1,93 @@ +var __spreadArray = + (this && this.__spreadArray) || + function (to, from, pack) { + if (pack || arguments.length === 2) { + for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + + ar[i] = from[i]; + } + } + } + + return to.concat(ar || Array.prototype.slice.call(from)); + }; + +import { fileURLToPath } from "node:url"; +import { mergeConfig, defineConfig, configDefaults, coverageConfigDefaults } from "vitest/config"; +import viteConfig from "./vite.config"; +import path from "node:path"; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; +import { playwright } from "@vitest/browser-playwright"; + +/** + * Coverage threshold for tests + * TODO: Increase test coverage to 80% + */ +var coverage = 40; +var dirname = + typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + +// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + coverage: { + provider: "v8", + thresholds: { + lines: coverage, // 58.84% + functions: coverage, // 50.82% + branches: coverage, // 47.15% + statements: coverage, // 55.74% + }, + exclude: __spreadArray( + __spreadArray([], coverageConfigDefaults.exclude, true), + ["e2e/**", ".storybook/**"], + false + ), + }, + projects: [ + { + extends: true, + test: { + name: "e2e", + environment: "jsdom", + root: fileURLToPath(new URL("./", import.meta.url)), + }, + }, + { + extends: true, + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + storybookTest({ + // The location of your Storybook config, main.js|ts + configDir: path.join(dirname, ".storybook"), + // This should match your package.json script to run Storybook + // The --ci flag will skip prompts and not open a browser + storybookScript: "yarn dev --ci", + tags: { include: ["test"], exclude: ["experimental"] }, + }), + ], + test: { + name: "storybook", + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [{ browser: "chromium" }], + }, + setupFiles: [".storybook/vitest.setup.ts"], + }, + }, + ], + exclude: __spreadArray( + __spreadArray([], configDefaults.exclude, true), + ["e2e/**", ".storybook/**"], + false + ), + }, + }) +); diff --git a/packages/components-vue/vitest.config.ts b/packages/components-vue/vitest.config.ts index bf4f36dc..c6082f22 100644 --- a/packages/components-vue/vitest.config.ts +++ b/packages/components-vue/vitest.config.ts @@ -21,10 +21,10 @@ export default mergeConfig( coverage: { provider: "v8", thresholds: { - lines: coverage, // 61.97% - functions: coverage, // 49.66% - branches: coverage, // 53.04% - statements: coverage, // 58.97% + lines: coverage, // 58.84% + functions: coverage, // 50.82% + branches: coverage, // 47.15% + statements: coverage, // 55.74% }, exclude: [...coverageConfigDefaults.exclude, "e2e/**", ".storybook/**"], }, diff --git a/packages/styles/src/components/action/_inputCheckbox.scss b/packages/styles/src/components/action/_inputCheckbox.scss index 59e91b2d..10ce8ea0 100644 --- a/packages/styles/src/components/action/_inputCheckbox.scss +++ b/packages/styles/src/components/action/_inputCheckbox.scss @@ -7,7 +7,7 @@ $radius-action: module.strip-unit(module.$size-radius-action); @layer definitions { - input[type^="c"].#{module.$component-input-checkbox} { + input[type="checkbox"].#{module.$component-input-checkbox} { @include module.action-toggle-styles; // Toggle size diff --git a/packages/styles/src/components/action/_inputRadio.scss b/packages/styles/src/components/action/_inputRadio.scss index 6b3e780f..c20b0570 100644 --- a/packages/styles/src/components/action/_inputRadio.scss +++ b/packages/styles/src/components/action/_inputRadio.scss @@ -7,7 +7,7 @@ $radius-action-round: module.strip-unit(module.$size-radius-action-round); @layer definitions { - input[type^="r"].#{module.$component-input-radio} { + input[type="radio"].#{module.$component-input-radio} { @include module.action-toggle-styles; // Toggle size diff --git a/packages/styles/src/layouts/_navList.scss b/packages/styles/src/layouts/_navList.scss index 8ffa848e..cb7c43de 100644 --- a/packages/styles/src/layouts/_navList.scss +++ b/packages/styles/src/layouts/_navList.scss @@ -12,22 +12,28 @@ @include utils.flex-box(row, nowrap, space-between, center); i.#{utils.$component-icon}, .#{utils.$component-svg} { - width: 1.8rem; + &.#{utils.$prefix-default}--indicator { + width: 1.8rem; + } } } nav.#{utils.$layout-list} { - > input[type^="c"] { + > input[type="radio"], + > input[type="checkbox"] { display: none; } &.#{utils.$status-is-active}, - > input[type^="c"]:checked ~ { + > input[type="radio"]:checked ~, + > input[type="checkbox"]:checked ~ { .#{utils.$status-toggle-list} { font-weight: utils.weight(bold); margin-bottom: 1rem; i.#{utils.$component-icon}, .#{utils.$component-svg} { - transform: rotate(180deg); + &.#{utils.$prefix-default}--indicator { + transform: rotate(180deg); + } } } .#{utils.$status-toggle-list} + {