diff options
author | 2023-08-27 20:12:31 +0200 | |
---|---|---|
committer | 2023-08-27 18:12:31 +0000 | |
commit | 3a63837d3d47e7adbdf024de8dca6a2736b5a55e (patch) | |
tree | 9980e9c37cd8666728d352d066693ec46f3a5b0f /src | |
parent | 81bfe57cb85690f4153683754ffb09f35a816dac (diff) | |
download | it-tools-3a63837d3d47e7adbdf024de8dca6a2736b5a55e.tar.gz it-tools-3a63837d3d47e7adbdf024de8dca6a2736b5a55e.tar.zst it-tools-3a63837d3d47e7adbdf024de8dca6a2736b5a55e.zip |
feat(new tool): iban validation and parser (#591)
Diffstat (limited to 'src')
-rw-r--r-- | src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts | 51 | ||||
-rw-r--r-- | src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts | 18 | ||||
-rw-r--r-- | src/tools/iban-validator-and-parser/iban-validator-and-parser.vue | 71 | ||||
-rw-r--r-- | src/tools/iban-validator-and-parser/index.ts | 12 | ||||
-rw-r--r-- | src/tools/index.ts | 3 | ||||
-rw-r--r-- | src/ui/c-key-value-list/c-key-value-list.types.ts | 9 | ||||
-rw-r--r-- | src/ui/c-key-value-list/c-key-value-list.vue | 37 | ||||
-rw-r--r-- | src/ui/c-text-copyable/c-text-copyable.demo.vue | 3 | ||||
-rw-r--r-- | src/ui/c-text-copyable/c-text-copyable.vue | 17 | ||||
-rw-r--r-- | src/ui/c-tooltip/c-tooltip.demo.vue | 17 | ||||
-rw-r--r-- | src/ui/c-tooltip/c-tooltip.vue | 27 |
11 files changed, 264 insertions, 1 deletions
diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts b/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts new file mode 100644 index 0000000..3501543 --- /dev/null +++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.e2e.spec.ts @@ -0,0 +1,51 @@ +import { type Page, expect, test } from '@playwright/test'; +import _ from 'lodash'; + +async function extractIbanInfo({ page }: { page: Page }) { + const tdHandles = await page.locator('table tr td').elementHandles(); + const tdTextContents = await Promise.all(tdHandles.map(el => el.textContent())); + + return _.chain(tdTextContents) + .map(tdTextContent => tdTextContent?.trim().replace(' Copy to clipboard', '')) + .chunk(2) + .value(); +} + +test.describe('Tool - Iban validator and parser', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/iban-validator-and-parser'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('IBAN validator and parser - IT Tools'); + }); + + test('iban info are extracted from a valid iban', async ({ page }) => { + await page.getByTestId('iban-input').fill('DE89370400440532013000'); + + const ibanInfo = await extractIbanInfo({ page }); + + expect(ibanInfo).toEqual([ + ['Is IBAN valid ?', 'Yes'], + ['Is IBAN a QR-IBAN ?', 'No'], + ['Country code', 'DE'], + ['BBAN', '370400440532013000'], + ['IBAN friendly format', 'DE89 3704 0044 0532 0130 00'], + ]); + }); + + test('invalid iban errors are displayed', async ({ page }) => { + await page.getByTestId('iban-input').fill('FR7630006060011234567890189'); + + const ibanInfo = await extractIbanInfo({ page }); + + expect(ibanInfo).toEqual([ + ['Is IBAN valid ?', 'No'], + ['IBAN errors', 'Wrong account bank branch checksumWrong IBAN checksum Copy to clipboard'], + ['Is IBAN a QR-IBAN ?', 'No'], + ['Country code', 'N/A'], + ['BBAN', 'N/A'], + ['IBAN friendly format', 'FR76 3000 6060 0112 3456 7890 189'], + ]); + }); +}); diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts b/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts new file mode 100644 index 0000000..bde71db --- /dev/null +++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.service.ts @@ -0,0 +1,18 @@ +import { ValidationErrorsIBAN } from 'ibantools'; + +export { getFriendlyErrors }; + +const ibanErrorToMessage = { + [ValidationErrorsIBAN.NoIBANProvided]: 'No IBAN provided', + [ValidationErrorsIBAN.NoIBANCountry]: 'No IBAN country', + [ValidationErrorsIBAN.WrongBBANLength]: 'Wrong BBAN length', + [ValidationErrorsIBAN.WrongBBANFormat]: 'Wrong BBAN format', + [ValidationErrorsIBAN.ChecksumNotNumber]: 'Checksum is not a number', + [ValidationErrorsIBAN.WrongIBANChecksum]: 'Wrong IBAN checksum', + [ValidationErrorsIBAN.WrongAccountBankBranchChecksum]: 'Wrong account bank branch checksum', + [ValidationErrorsIBAN.QRIBANNotAllowed]: 'QR-IBAN not allowed', +}; + +function getFriendlyErrors(errorCodes: ValidationErrorsIBAN[]) { + return errorCodes.map(errorCode => ibanErrorToMessage[errorCode]).filter(Boolean); +} diff --git a/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue b/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue new file mode 100644 index 0000000..d5cdc02 --- /dev/null +++ b/src/tools/iban-validator-and-parser/iban-validator-and-parser.vue @@ -0,0 +1,71 @@ +<script setup lang="ts"> +import { extractIBAN, friendlyFormatIBAN, isQRIBAN, validateIBAN } from 'ibantools'; +import { getFriendlyErrors } from './iban-validator-and-parser.service'; +import type { CKeyValueListItems } from '@/ui/c-key-value-list/c-key-value-list.types'; + +const rawIban = ref(''); + +const ibanInfo = computed<CKeyValueListItems>(() => { + const iban = rawIban.value.toUpperCase().replace(/\s/g, '').replace(/-/g, ''); + + if (iban === '') { + return []; + } + + const { valid: isIbanValid, errorCodes } = validateIBAN(iban); + const { countryCode, bban } = extractIBAN(iban); + const errors = getFriendlyErrors(errorCodes); + + return [ + + { + label: 'Is IBAN valid ?', + value: isIbanValid, + showCopyButton: false, + }, + { + label: 'IBAN errors', + value: errors.length === 0 ? undefined : errors, + hideOnNil: true, + showCopyButton: false, + }, + { + label: 'Is IBAN a QR-IBAN ?', + value: isQRIBAN(iban), + showCopyButton: false, + }, + { + label: 'Country code', + value: countryCode, + }, + { + label: 'BBAN', + value: bban, + }, + { + label: 'IBAN friendly format', + value: friendlyFormatIBAN(iban), + }, + ]; +}); + +const ibanExamples = [ + 'FR7630006000011234567890189', + 'DE89370400440532013000', + 'GB29NWBK60161331926819', +]; +</script> + +<template> + <div> + <c-input-text v-model:value="rawIban" placeholder="Enter an IBAN to check for validity..." test-id="iban-input" /> + + <c-key-value-list :items="ibanInfo" my-5 /> + + <c-card title="Valid IBAN examples"> + <div v-for="iban in ibanExamples" :key="iban"> + <c-text-copyable :value="iban" font-mono :displayed-value="friendlyFormatIBAN(iban)" /> + </div> + </c-card> + </div> +</template> diff --git a/src/tools/iban-validator-and-parser/index.ts b/src/tools/iban-validator-and-parser/index.ts new file mode 100644 index 0000000..b0cae50 --- /dev/null +++ b/src/tools/iban-validator-and-parser/index.ts @@ -0,0 +1,12 @@ +import { defineTool } from '../tool'; +import Bank from '~icons/mdi/bank'; + +export const tool = defineTool({ + name: 'IBAN validator and parser', + path: '/iban-validator-and-parser', + description: 'Validate and parse IBAN numbers. Check if IBAN is valid and get the country, BBAN, if it is a QR-IBAN and the IBAN friendly format.', + keywords: ['iban', 'validator', 'and', 'parser', 'bic', 'bank'], + component: () => import('./iban-validator-and-parser.vue'), + icon: Bank, + createdAt: new Date('2023-08-26'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 39b34ad..15770b5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; +import { tool as ibanValidatorAndParser } from './iban-validator-and-parser'; import { tool as stringObfuscator } from './string-obfuscator'; import { tool as textDiff } from './text-diff'; import { tool as emojiPicker } from './emoji-picker'; @@ -151,7 +152,7 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Data', - components: [phoneParserAndFormatter], + components: [phoneParserAndFormatter, ibanValidatorAndParser], }, ]; diff --git a/src/ui/c-key-value-list/c-key-value-list.types.ts b/src/ui/c-key-value-list/c-key-value-list.types.ts new file mode 100644 index 0000000..40cc4ba --- /dev/null +++ b/src/ui/c-key-value-list/c-key-value-list.types.ts @@ -0,0 +1,9 @@ +export interface CKeyValueListItem { + label: string + value: string | string[] | number | boolean | undefined | null + hideOnNil?: boolean + placeholder?: string + showCopyButton?: boolean +} + +export type CKeyValueListItems = CKeyValueListItem[]; diff --git a/src/ui/c-key-value-list/c-key-value-list.vue b/src/ui/c-key-value-list/c-key-value-list.vue new file mode 100644 index 0000000..e3b19af --- /dev/null +++ b/src/ui/c-key-value-list/c-key-value-list.vue @@ -0,0 +1,37 @@ +<script lang="ts" setup> +import _ from 'lodash'; +import type { CKeyValueListItems } from './c-key-value-list.types'; + +const props = withDefaults(defineProps<{ items?: CKeyValueListItems }>(), { items: () => [] }); +const { items } = toRefs(props); + +const formattedItems = computed(() => items.value.filter(item => !_.isNil(item.value) || !item.hideOnNil)); +</script> + +<template> + <table border-collapse table-fixed> + <tr v-for="item in formattedItems" :key="item.label"> + <td py-1 pr-2 text-right font-bold> + {{ item.label }} + </td> + + <td v-if="_.isArray(item.value)"> + <div v-for="value in item.value" :key="value"> + <c-text-copyable :value="value" :show-icon="item.showCopyButton ?? true" /> + </div> + </td> + <td v-else-if="_.isBoolean(item.value)"> + <c-text-copyable :value="item.value ? 'true' : 'false'" :displayed-value="item.value ? 'Yes' : 'No'" :show-icon="item.showCopyButton ?? true" /> + </td> + <td v-else-if="_.isNumber(item.value)" font-mono> + <c-text-copyable :value="String(item.value)" :show-icon="item.showCopyButton ?? true" /> + </td> + <td v-else-if="_.isNil(item.value) || item.value === ''" op-70> + {{ item.placeholder ?? 'N/A' }} + </td> + <td v-else> + <c-text-copyable :value="item.value" :show-icon="item.showCopyButton ?? true" /> + </td> + </tr> + </table> +</template> diff --git a/src/ui/c-text-copyable/c-text-copyable.demo.vue b/src/ui/c-text-copyable/c-text-copyable.demo.vue new file mode 100644 index 0000000..e2aeb1d --- /dev/null +++ b/src/ui/c-text-copyable/c-text-copyable.demo.vue @@ -0,0 +1,3 @@ +<template> + <c-text-copyable value="value" displayed-value="displayedValue" /> +</template> diff --git a/src/ui/c-text-copyable/c-text-copyable.vue b/src/ui/c-text-copyable/c-text-copyable.vue new file mode 100644 index 0000000..b78e4cd --- /dev/null +++ b/src/ui/c-text-copyable/c-text-copyable.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import { useCopy } from '@/composable/copy'; + +const props = withDefaults(defineProps<{ value?: string; displayedValue?: string; showIcon?: boolean }>(), { value: '', displayedValue: undefined, showIcon: true }); +const { value, displayedValue, showIcon } = toRefs(props); + +const { copy, isJustCopied } = useCopy({ source: value, createToast: false }); +</script> + +<template> + <c-tooltip :tooltip="isJustCopied ? 'Copied!' : 'Copy to clipboard'" cursor-pointer @click="copy"> + <span flex items-center gap-2> + {{ displayedValue ?? value }} + <icon-mdi-content-copy v-if="showIcon" op-40 /> + </span> + </c-tooltip> +</template> diff --git a/src/ui/c-tooltip/c-tooltip.demo.vue b/src/ui/c-tooltip/c-tooltip.demo.vue new file mode 100644 index 0000000..d385257 --- /dev/null +++ b/src/ui/c-tooltip/c-tooltip.demo.vue @@ -0,0 +1,17 @@ +<template> + <div> + <c-tooltip> + Hover me + + <template #tooltip> + Tooltip content + </template> + </c-tooltip> + </div> + + <div mt-5> + <c-tooltip tooltip="Tooltip content"> + Hover me + </c-tooltip> + </div> +</template> diff --git a/src/ui/c-tooltip/c-tooltip.vue b/src/ui/c-tooltip/c-tooltip.vue new file mode 100644 index 0000000..cc48fe1 --- /dev/null +++ b/src/ui/c-tooltip/c-tooltip.vue @@ -0,0 +1,27 @@ +<script setup lang="ts"> +const props = withDefaults(defineProps<{ tooltip?: string }>(), { tooltip: '' }); +const { tooltip } = toRefs(props); + +const targetRef = ref(); +const isTargetHovered = useElementHover(targetRef); +</script> + +<template> + <div class="relative" inline-block> + <div ref="targetRef"> + <slot /> + </div> + + <div + class="absolute bottom-100% left-50% z-10 mb-5px whitespace-nowrap rounded bg-black px-12px py-6px text-sm text-white shadow-lg transition transition transition-duration-0.2s -translate-x-1/2" + :class="{ + 'op-0 scale-0': isTargetHovered === false, + 'op-100 scale-100': isTargetHovered, + }" + > + <slot name="tooltip"> + {{ tooltip }} + </slot> + </div> + </div> +</template> |