feat: adding alert component with markdown (#19152)

Co-authored-by: ElevateBart <ledouxb@gmail.com>
Co-authored-by: Barthélémy Ledoux <bart@cypress.io>
This commit is contained in:
Jessica Sachs
2021-12-02 22:42:02 -05:00
committed by GitHub
parent 195da20ff5
commit 30a14ed65e
24 changed files with 843 additions and 200 deletions
+1
View File
@@ -4,6 +4,7 @@
"dictionaryDefinitions": [],
"dictionaries": [],
"words": [
"composables",
"Iconify",
"Lachlan",
"msapplication",
+1 -1
View File
@@ -9,6 +9,6 @@ module.exports = {
localSchemaFile: path.join(__dirname, 'packages/graphql/schemas/schema.graphql'),
},
tagName: 'gql',
includes: [path.join(__dirname, 'packages/{launchpad,app,frontend-shared}/src/**/*.vue')],
includes: [path.join(__dirname, 'packages/{launchpad,app,frontend-shared}/src/**/*.{vue,ts,js,tsx,jsx}')],
},
}
+14 -14
View File
@@ -50,9 +50,9 @@ generates:
flattenGeneratedTypes: true
schema: 'packages/graphql/schemas/schema.graphql'
documents:
- './packages/frontend-shared/src/gql-components/**/*.vue'
- './packages/app/src/**/*.vue'
- './packages/launchpad/src/**/*.vue'
- './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
plugins:
- add:
content: '/* eslint-disable */'
@@ -92,23 +92,23 @@ generates:
- 'typescript'
###
# All of the GraphQL Query/Mutation documents we import for use in the .vue
# All of the GraphQL Query/Mutation documents we import for use in the .{vue,ts,tsx,js,jsx}
# files for useQuery / useMutation, as well as types associated with the fragments
###
'./packages/launchpad/src/generated/graphql.ts':
documents:
- './packages/launchpad/src/**/*.vue'
- './packages/frontend-shared/src/**/*.vue'
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueOperations
'./packages/app/src/generated/graphql.ts':
documents:
- './packages/app/src/**/*.vue'
- './packages/frontend-shared/src/**/*.vue'
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueOperations
'./packages/frontend-shared/src/generated/graphql.ts':
documents: './packages/frontend-shared/src/gql-components/**/*.vue'
documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueOperations
###
# All GraphQL documents imported into the .spec.tsx files for component testing.
@@ -117,16 +117,16 @@ generates:
###
'./packages/launchpad/src/generated/graphql-test.ts':
documents:
- './packages/launchpad/src/**/*.vue'
- './packages/frontend-shared/src/**/*.vue'
- './packages/launchpad/src/**/*.{vue,ts,tsx,js,jsx}'
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueTesting
'./packages/app/src/generated/graphql-test.ts':
documents:
- './packages/app/src/**/*.vue'
- './packages/frontend-shared/src/**/*.vue'
- './packages/app/src/**/*.{vue,ts,tsx,js,jsx}'
- './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueTesting
'./packages/frontend-shared/src/generated/graphql-test.ts':
documents: './packages/frontend-shared/src/gql-components/**/*.vue'
documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueTesting
@@ -166,6 +166,10 @@ export const cyColors = {
...customColors.red,
DEFAULT: customColors.red[500],
},
info: {
...customColors.indigo,
DEFAULT: customColors.indigo[500],
},
warning: {
...customColors.orange,
DEFAULT: customColors.orange[500],
+2
View File
@@ -15,6 +15,8 @@
"generate-shiki-theme": "node ./script/generate-shiki-theme.js"
},
"dependencies": {
"@toycode/markdown-it-class": "1.2.4",
"markdown-it": "12.2.0",
"shiki": "^0.9.12"
},
"devDependencies": {
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 13L13 3M3 3L13 13"
class="icon-dark" stroke="#1B1E2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1,255 @@
import CoffeeIcon from '~icons/mdi/coffee'
import LoadingIcon from '~icons/mdi/loading'
import faker from 'faker'
import Alert from './Alert.vue'
import { defaultMessages } from '../locales/i18n'
import { ref } from 'vue'
const messages = defaultMessages.components.alert
const alertBodySelector = '[data-testid=alert-body]'
const alertHeaderSelector = '[data-testid=alert-header]'
const suffixIconSelector = '[data-testid=alert-suffix-icon]'
const prefixIconSelector = '[data-testid=alert-prefix-icon]'
// This divider should eventually be tested inside of a visual regression test.
const dividerLineSelector = '[data-testid=alert-body-divider]'
const dismissSelector = `[aria-label=${messages.dismissAriaLabel}]`
const alertTitle = faker.hacker.phrase()
const alertBodyContent = faker.lorem.sentences(2)
const makeDismissibleProps = () => {
const modelValue = ref(true)
const methods = {
'onUpdate:modelValue': (newValue) => {
modelValue.value = newValue
},
}
return { modelValue, methods }
}
const prefixIcon = () => <CoffeeIcon data-testid="coffee-icon"/>
const suffixIcon = () => <LoadingIcon data-testid="loading-icon" class="animate-spin"/>
describe('<Alert />', () => {
describe('classes', () => {
it('can change the text and background color for the alert', () => {
cy.mount(() => <Alert headerClass="underline text-pink-500" alertClass="bg-pink-100" icon={suffixIcon}/>)
})
})
describe('prefix', () => {
it('renders the icon prop as a prefix', () => {
cy.mount(() => <Alert status="info" icon={CoffeeIcon} />)
.get(prefixIconSelector).should('be.visible')
})
it('renders the prefixIcon slot', () => {
cy.mount(() => <Alert v-slots={{ prefixIcon }} />)
.get('[data-testid=coffee-icon]').should('be.visible')
})
it('renders the prefixIcon slot even when an icon is passed in', () => {
cy.mount(() => (<Alert
v-slots={{ prefixIcon }}
icon={() => <LoadingIcon data-testid="loading-icon" />}
/>))
.get('[data-testid=coffee-icon]').should('be.visible')
.get('[data-testid=loading-icon]').should('not.exist')
})
})
describe('suffix', () => {
it('renders the suffixIcon slot', () => {
cy.mount(() => <Alert title="Alert" v-slots={{ suffixIcon }} />)
.get('[data-testid=loading-icon]').should('be.visible')
})
})
describe('static', () => {
it('renders any body content and is open by default', () => {
cy.mount(() => (
<div class="space-y-2 text-center p-4">
<Alert title="Alert">
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
</Alert>
</div>
))
cy.get('[data-testid=body-content]').should('be.visible')
})
})
describe('dismissible', () => {
it('renders any body content and is open by default', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => (
<div class="space-y-2 text-center p-4">
<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}>
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
</Alert>
</div>
))
cy.get('[data-testid=body-content]').should('be.visible')
})
it('cannot be collapsed', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => (<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}>
<p data-testid="body-content">{ faker.lorem.paragraphs(5) }</p>
</Alert>))
.get(alertBodySelector).should('be.visible')
.get(alertHeaderSelector).click()
.get(alertBodySelector).should('be.visible')
})
it('has a "dismiss" suffixIcon by default', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => (
<div class="space-y-2 text-center p-4">
<Alert title="Alert" status="info" dismissible modelValue={modelValue.value} {...methods}/>
</div>
))
cy.get(suffixIconSelector)
.should('be.visible')
.and('have.attr', 'aria-label', messages.dismissAriaLabel)
})
it('can be dismissed', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => (<div class="space-y-2 text-center p-4">
<Alert title="Alert" dismissible modelValue={modelValue.value} {...methods}/>
</div>))
cy.get(suffixIconSelector).focus().click()
.get(alertHeaderSelector).should('not.exist')
})
it('accepts a custom dismiss icon, via slot', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => <Alert title="Alert" dismissible v-slots={{ suffixIcon }} modelValue={modelValue.value} {...methods}/>)
})
it('can create a dismiss button via the suffixIcons slot props', () => {
const { modelValue, methods } = makeDismissibleProps()
const slots = {
suffixIcon ({ onClick, ariaLabel }) {
return <CoffeeIcon onClick={onClick} aria-label={ariaLabel}/>
},
}
cy.mount(() => <Alert title="Alert" dismissible v-slots={slots} modelValue={modelValue.value} {...methods} />)
cy.get(dismissSelector).click()
cy.get(alertHeaderSelector).should('not.exist')
})
})
describe('with body content', () => {
it('shows the body content initially', () => {
const types = [{ 'dismissible': true }, { 'static': true }] as const
const alerts = types.map((type) => {
const { modelValue, methods } = makeDismissibleProps()
return (<div>
<h2 class="capitalize">{Object.keys(type)[0]}</h2>
<Alert title={alertTitle} modelValue={modelValue.value} {...type} {...methods}>
<p>{ faker.lorem.paragraphs(2) }</p>
</Alert>
</div>)
})
cy.mount(() => <div class="space-y-2 p-4">{ alerts }</div>)
cy.get(alertBodySelector).each((el) => cy.wrap(el).should('be.visible').get(dividerLineSelector).should('be.visible'))
})
})
describe('without body content', () => {
it('can be dismissed', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(<Alert collapsible title={alertTitle} modelValue={modelValue.value} {...methods}/>)
cy.get(alertBodySelector).should('not.exist')
cy.get(alertHeaderSelector).click().get(alertBodySelector).should('not.exist')
})
it('renders each alert type without the divider line', () => {
const types = [{ 'dismissible': true }, { 'collapsible': true }, { 'static': true }]
const alerts = types.map((type) => (<div>
<h2 class="capitalize">{Object.keys(type)[0]}</h2>
<Alert {...type} title={alertTitle} />
</div>
))
cy.mount(() => <div class="space-y-2 p-4">{ alerts }</div>)
cy.get(alertHeaderSelector).each((el) => cy.wrap(el).click()).get(dividerLineSelector).should('not.exist')
})
})
describe('collapsible', () => {
beforeEach(() => {
cy.mount(() => (
<div class="space-y-2 text-center p-4">
<Alert status="success" collapsible icon={CoffeeIcon} title={alertTitle}>{alertBodyContent}</Alert>
</div>
))
})
it('should not have a suffix icon', () => {
cy.get(suffixIconSelector).should('not.exist')
cy.get(dismissSelector).should('not.exist')
})
it('renders the alert title', () => {
cy.get(alertHeaderSelector).should('be.visible').and('have.text', alertTitle)
})
it('the body content is collapsed by default', () => {
cy.get(alertBodySelector).should('not.exist')
})
it('can be expanded and collapsed to show the body content', () => {
cy.get(alertHeaderSelector).click()
cy.get(alertBodySelector).should('be.visible').and('have.text', alertBodyContent)
cy.get(alertHeaderSelector).click()
cy.get(alertBodySelector).should('not.exist')
})
})
})
describe('playground', () => {
it('renders', () => {
const { modelValue, methods } = makeDismissibleProps()
cy.mount(() => {
return (
<div class="space-y-2 text-center p-4">
<Alert status="success" collapsible icon={CoffeeIcon} title="Coffee, please">
Delicious. Yum.
<button class="bg-white rounded ml-2 px-2">Focusable</button>
</Alert>
<Alert status="info" collapsible title="An info alert">Just letting you know what's up.</Alert>
<Alert status="warning">Nothing good is happening here!</Alert>
<Alert icon={CoffeeIcon}
dismissible
status="error"
modelValue={modelValue.value}
{...methods}>Close me, please!</Alert>
<Alert v-slots={{ suffixIcon }} collapsible status="default">A notice.</Alert>
<Alert>Default alert</Alert>
</div>
)
})
})
})
@@ -0,0 +1,183 @@
<template>
<Collapsible
v-if="modelValue"
lazy
:initially-open="initiallyOpen"
:disable="!canCollapse"
class="rounded-t rounded-b outline-none overflow-hidden group"
:class="[
classes.alertClass,
classes.headerClass,
{[`hocus-default border-1 border-transparent rounded ${classes.ring}`]: canCollapse}]"
height="300"
>
<template #target="{ open }">
<div
data-testid="alert-header"
class="grid grid-cols-1 group"
:class="{
'cursor-pointer': canCollapse,
}"
>
<AlertHeader
:title="title"
v-bind="classes"
:header-class="canCollapse ? 'group-hocus:underline' : ''"
:prefix-icon="prefix?.icon"
:prefix-icon-class="open ? prefix?.classes + ' rotate-180' : prefix?.classes"
:suffix-icon-aria-label="props.dismissible ? t('components.alert.dismissAriaLabel') : ''"
:suffix-icon="props.dismissible ? DeleteIcon : null"
data-testid="alert"
class="rounded min-w-200px p-16px"
@suffixIconClicked="$emit('update:modelValue', !modelValue)"
>
<template
v-if="$slots.prefixIcon"
#prefixIcon="slotProps"
>
<slot
name="prefixIcon"
v-bind="slotProps"
/>
</template>
<template
v-if="$slots.suffixIcon"
#suffixIcon="slotProps"
>
<slot
name="suffixIcon"
v-bind="slotProps"
/>
</template>
</AlertHeader>
<div
v-if="open"
data-testid="alert-body-divider"
class="mx-auto h-1px transform w-[calc(100%-32px)] translate-y-1px"
:class="[classes.dividerClass]"
/>
</div>
</template>
<div
v-if="$slots.default"
class="text-left p-16px"
data-testid="alert-body"
>
<slot />
</div>
</Collapsible>
</template>
<script lang="ts">
export type AlertStatus = 'error' | 'warning' | 'info' | 'default' | 'success'
export type AlertClasses = {
headerClass: string,
suffixIconClass: string
suffixButtonClass: string
alertClass: string
dividerClass: string
ring: string
}
</script>
<script lang="ts" setup>
import AlertHeader from './AlertHeader.vue'
import DeleteIcon from '~icons/cy/delete_x16.svg'
import { computed, useSlots, FunctionalComponent, SVGAttributes } from 'vue'
import ChevronDown from '~icons/cy/chevron-down-small_x16.svg'
import { useI18n } from '@cy/i18n'
import Collapsible from './Collapsible.vue'
const { t } = useI18n()
const slots = useSlots()
defineEmits<{
(eventName: 'update:modelValue', value: boolean): void
}>()
const props = withDefaults(defineProps<{
title?: string
status?: AlertStatus
icon?: FunctionalComponent<SVGAttributes, {}>,
headerClass?: string,
alertClass?: string,
dismissible?: boolean,
collapsible?: boolean,
modelValue?: boolean,
}>(), {
modelValue: true,
alertClass: undefined,
status: 'info',
icon: undefined,
headerClass: undefined,
})
const title = computed(() => props.title ?? 'Alert')
const alertStyles: Record<AlertStatus, AlertClasses> = {
default: {
headerClass: 'text-gray-800',
suffixIconClass: 'icon-dark-gray-600',
suffixButtonClass: 'text-gray-600',
alertClass: 'bg-gray-100',
dividerClass: 'bg-gray-300',
ring: 'hocus:(ring-gray-200 border-gray-300)',
},
info: {
headerClass: 'text-info-700',
suffixIconClass: 'icon-dark-info-500',
suffixButtonClass: 'text-info-500',
alertClass: 'bg-info-100',
dividerClass: 'bg-info-300',
ring: 'hocus:(ring-info-200 border-info-300)',
},
warning: {
headerClass: 'text-warning-500',
suffixIconClass: 'icon-dark-warning-500',
suffixButtonClass: 'text-warning-500',
alertClass: 'bg-warning-100',
dividerClass: 'bg-warning-300',
ring: 'hocus:(ring-warning-200 border-warning-300)',
},
error: {
headerClass: 'text-error-600',
suffixIconClass: 'icon-dark-error-500',
suffixButtonClass: 'text-error-500',
alertClass: 'bg-error-100',
dividerClass: 'bg-error-300',
ring: 'hocus:(ring-error-200 border-error-300)',
},
success: {
headerClass: 'text-success-600',
suffixIconClass: 'icon-dark-success-500',
suffixButtonClass: 'text-success-500',
alertClass: 'bg-success-100',
dividerClass: 'bg-success-300',
ring: 'hocus:(ring-success-200 border-success-300)',
},
}
const classes = computed(() => {
return {
...alertStyles[props.status],
headerClass: props.headerClass ?? alertStyles[props.status].headerClass,
alertClass: props.alertClass ?? alertStyles[props.status].alertClass,
}
})
const canCollapse = computed(() => slots.default && props.collapsible)
const initiallyOpen = computed(() => slots.default && !props.collapsible)
const prefix = computed(() => {
if (props.icon) return { icon: props.icon }
if (canCollapse.value) {
return {
icon: ChevronDown,
classes: 'transition transform duration-150 w-16px h-16px',
}
}
return {}
})
</script>
@@ -0,0 +1,14 @@
import AlertHeader from './AlertHeader.vue'
import CoffeeIcon from '~icons/mdi/coffee'
describe('<AlertHeader />', () => {
it('playground', () => {
cy.mount(() => (
<div class="text-center p-4">
<AlertHeader prefixIcon={CoffeeIcon} title="Coffee, please" suffixIcon={null}/>
<AlertHeader title="Alert" />
<AlertHeader title="Alert" />
</div>
))
})
})
@@ -0,0 +1,70 @@
<template>
<div
class="flex gap-8px group items-center relative"
:class="[alertClass, headerClass]"
>
<slot name="prefixIcon">
<component
:is="prefixIcon"
v-if="prefixIcon"
data-testid="alert-prefix-icon"
class="h-16px w-16px icon-dark-current"
:class="prefixIconClass"
/>
</slot>
<h3
class="flex-grow font-medium text-left leading-normal underline-current"
:class="headerClass"
>
<slot name="title">
{{ title }}
</slot>
</h3>
<div class="relative shrink">
<slot
name="suffixIcon"
v-bind="{ ariaLabel: suffixIconAriaLabel, buttonClasses: suffixButtonClass, iconClasses: suffixIconClass, onClick: onSuffixIconClicked }"
>
<button
v-if="suffixIcon"
data-testid="alert-suffix-icon"
:aria-label="suffixIconAriaLabel"
class="rounded-full flex outline-none h-32px -top-16px -right-8px w-32px hocus:ring-current items-center justify-center absolute hocus:ring-1"
:class="suffixButtonClass"
@click="onSuffixIconClicked"
>
<component
:is="suffixIcon"
class="h-16px w-16px"
:class="suffixIconClass"
/>
</button>
</slot>
</div>
</div>
</template>
<script lang="ts" setup>
import type { FunctionalComponent, SVGAttributes } from 'vue'
const emit = defineEmits<{
(eventName: 'suffixIconClicked'): void
}>()
/* eslint-disable vue/require-default-prop */
defineProps<{
title: string
prefixIcon?: FunctionalComponent<SVGAttributes, {}> | null
suffixIcon?: FunctionalComponent<SVGAttributes, {}> | null
suffixIconAriaLabel?: string
alertClass?: string
prefixIconClass?: string
suffixIconClass?: string
headerClass?: string
suffixButtonClass?: string
}>()
const onSuffixIconClicked = () => {
emit('suffixIconClicked')
}
</script>
@@ -1,16 +1,16 @@
<template>
<div
tabindex="0"
:tabindex="disable ? '-1' : '0'"
data-cy="collapsible"
@keypress.space.enter.self="toggle"
@keypress.space.enter.self="!disable && toggle()"
>
<div
data-cy="collapsible-header"
:aria-expanded="isOpen"
class="rounded-t focus:outline-indigo-500"
:class="{'rounded-b': !isOpen}"
@click="toggle"
@keypress.space.enter.self="toggle"
@click="!disable && toggle()"
@keypress.space.enter.self="!disable && toggle()"
>
<slot
name="target"
@@ -41,11 +41,13 @@ import { useToggle } from '@vueuse/core'
const props = withDefaults(defineProps<{
maxHeight?: string
initiallyOpen?: boolean
lazy?: boolean
lazy?: boolean,
disable?: boolean,
}>(), {
initiallyOpen: false,
maxHeight: '500px',
lazy: false,
disable: false,
})
const [isOpen, toggle] = useToggle(props.initiallyOpen)
@@ -191,7 +191,7 @@ $offset: 1.1em;
// Keep bg-gray-50 synced with the box-shadows.
.line::before, .line:first-child::before {
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky group-hocus:bg-red-500;
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky;
left: 0px !important;
content: counter(step);
counter-increment: step;
@@ -1,6 +1,6 @@
import Markdown from './Markdown.vue'
import UseMarkdownExample from './UseMarkdownExample.vue'
describe('<Markdown />', () => {
describe('useMarkdown', () => {
it('renders styled markdown', () => {
const text = `
# Heading 1
@@ -32,13 +32,12 @@ const heres = {
`
cy.mount(<Markdown
cy.mount(<UseMarkdownExample
options={{ classes: { code: ['bg-pink-200 text-pink-600'], pre: ['bg-orange-100', 'text-orange-500'] } }}
text={text}
codeClass='bg-gray-100 border-gray-300'
/>)
cy.get('code').first().should('have.class', 'bg-gray-100 border-gray-300')
cy.get('pre').first().should('have.class', 'bg-gray-100 border-gray-300')
cy.get('ul').should('have.class', 'list-disc')
cy.get('code').first().should('have.class', 'bg-pink text-pink-600')
})
})
@@ -0,0 +1,20 @@
<template>
<div
ref="target"
v-html="markdown"
/>
</template>
<script lang="ts" setup>
import { useMarkdown, UseMarkdownOptions } from '../useMarkdown'
import { ref } from 'vue'
const props = defineProps<{
text: string,
options: UseMarkdownOptions
}>()
const target = ref()
const { markdown } = useMarkdown(target, props.text, props.options)
</script>
@@ -0,0 +1,113 @@
/**
* Add styling for markdown components here.
* NOTICE: For Syntax Highlighting, please use the ShikiHighlight component.
* We could eventually use Shiki as a Markdown plugin, but I don't want to get into it right now.
*/
import { computed, Ref } from 'vue'
import MarkdownIt from 'markdown-it'
import MarkdownItClass from '@toycode/markdown-it-class'
import { useEventListener, whenever } from '@vueuse/core'
import { useExternalLink } from '../gql-components/useExternalLink'
import { mapValues, isArray, flatten } from 'lodash'
export interface UseMarkdownOptions {
openExternal?: boolean
classes?: {
h1?: string[] | string
h2?: string[] | string
h3?: string[] | string
h4?: string[] | string
h5?: string[] | string
h6?: string[] | string
pre?: string[] | string
p?: string[] | string
a?: string[] | string
ul?: string[] | string
li?: string[] | string
ol?: string[] | string
code?: string[] | string
}
}
const defaultClasses = {
h1: ['font-medium', 'text-4xl', 'mb-6'],
h2: ['font-medium', 'text-3xl', 'mb-5'],
h3: ['font-medium', 'text-2xl', 'mb-4'],
h4: ['font-medium', 'text-1xl', 'mb-3'],
h5: ['font-medium', 'text-sm', 'mb-3'],
h6: ['font-medium', 'text-xs', 'mb-3'],
p: ['my-3 first:mt-0 text-sm'],
pre: ['rounded p-3'],
code: [`font-medium rounded text-sm px-4px py-2px`],
a: ['text-blue-500', 'hover:underline text-sm'],
ul: ['list-disc pl-6 my-3 text-sm'],
ol: ['list-decimal pl-6 my-3 text-sm'],
}
const buildClasses = (options) => {
// --- Normalize + Merge the classes ---
// Array notation supports a single class or space-delimited classes.
// Input: `['bg-pink text-pink-500', 'text-medium']`
// Output: `[...defaults, 'bg-pink', 'text-pink-500', 'text-medium']`
// String notation is also supported and split by empty space
// Input: `'bg-pink text-pink-500'`
// Output: `[...defaults, 'bg-pink', text-pink-500']`
const _classes = defaultClasses // Constant above
const buildFlat = (value) => {
if (isArray(value)) {
return flatten<string>(value).map((arrValue) => arrValue.split(' '))
}
return value?.split(' ') ?? []
}
// Transform each value from defaultClasses and merge it with the user
// input classes.
if (options.classes) {
return mapValues(_classes, (defaultValue, key) => {
const inputClasses = buildFlat(options.classes[key])
return flatten([...buildFlat(defaultValue), ...inputClasses])
})
}
return _classes
}
export const useMarkdown = (target: Ref<HTMLElement>, text: string, options: UseMarkdownOptions = {}) => {
options.openExternal = options.openExternal || true
const classes = buildClasses(options)
const md = MarkdownIt({
html: true,
linkify: true,
highlight (str) {
return `<pre class="${classes.pre.join(' ')}"><code>${str}</code></pre>`
},
})
md.use(MarkdownItClass, classes)
if (options.openExternal) {
const open = useExternalLink()
whenever(target, () => {
useEventListener(target, 'click', (e: MouseEvent) => {
e.preventDefault()
const url = (e.target as HTMLElement).getAttribute('href')
if (url) {
open(url)
}
})
})
}
return {
markdown: computed(() => md.render(text, { sanitize: true })),
}
}
@@ -4,7 +4,7 @@
data-cy="external"
:href="props.href"
:use-default-hocus="props.useDefaultHocus"
@click.prevent="openExternal"
@click.prevent="open"
><slot /></BaseLink>
</template>
@@ -18,16 +18,7 @@ export default defineComponent({
<script setup lang="ts">
import BaseLink from '../components/BaseLink.vue'
import { ExternalLink_OpenExternalDocument } from '../generated/graphql'
import { gql, useMutation } from '@urql/vue'
gql`
mutation ExternalLink_OpenExternal ($url: String!) {
openExternal(url: $url)
}
`
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
import { useExternalLink } from '../gql-components/useExternalLink'
const props = withDefaults(defineProps<{
href: string,
@@ -36,7 +27,5 @@ const props = withDefaults(defineProps<{
useDefaultHocus: true,
})
const openExternal = () => {
openExternalMutation.executeMutation({ url: props.href })
}
const open = useExternalLink(props.href)
</script>
@@ -0,0 +1,24 @@
import { ExternalLink_OpenExternalDocument } from '../generated/graphql'
import { gql, useMutation } from '@urql/vue'
import type { MaybeRef } from '@vueuse/core'
import { unref } from 'vue'
gql`
mutation ExternalLink_OpenExternal ($url: String!) {
openExternal(url: $url)
}
`
export const useExternalLink = ($href?: MaybeRef<string>) => {
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
return (href?: string) => {
const resolvedHref = unref(typeof href === 'string' ? href : $href)
if (!resolvedHref) {
return new Error(`Cannot open external link. Possible urls passed in were ${{ localHref: href, initialHref: unref($href) }}`)
}
return openExternalMutation.executeMutation({ url: resolvedHref })
}
}
@@ -20,6 +20,9 @@
},
"select": {
"placeholder": "Choose an option..."
},
"alert": {
"dismissAriaLabel": "Dismiss"
}
},
"clipboard": {
@@ -1,65 +0,0 @@
<template>
<div
:class="props.class"
@click.prevent="openExternal"
v-html="compiledMarkdown"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import MarkdownItClass from '@toycode/markdown-it-class'
import { gql, useMutation } from '@urql/vue'
import { ExternalLink_OpenExternalDocument } from '../../generated/graphql'
gql`
mutation ExternalLink_OpenExternal ($url: String!) {
openExternal(url: $url)
}
`
const openExternalMutation = useMutation(ExternalLink_OpenExternalDocument)
const props = withDefaults(defineProps<{
class: string,
codeClass: string,
text: string,
}>(), {
class: '',
codeClass: '',
})
const md = MarkdownIt({
html: true,
linkify: true,
highlight (str) {
return `<pre class="border rounded-sm p-3 ${props.codeClass}"><code>${str}</code></pre>`
},
})
// defines classes to apply to elements that may be in the rendered markdown
// <pre> is handled above in the `highlight` method
md.use(MarkdownItClass, {
h1: ['font-medium', 'text-4xl', 'mb-6'],
h2: ['font-medium', 'text-3xl', 'mb-5'],
h3: ['font-medium', 'text-2xl', 'mb-4'],
h4: ['font-medium', 'text-1xl', 'mb-3'],
h5: ['font-medium', 'text-sm', 'mb-3'],
h6: ['font-medium', 'text-xs', 'mb-3'],
p: ['my-3 first:mt-0'],
code: [`border rounded-sm px-2px ${props.codeClass}`],
a: ['text-blue-500', 'hover:underline'],
ul: ['list-disc pl-6 my-3'],
ol: ['list-decimal pl-6 my-3'],
})
const compiledMarkdown = computed(() => md.render(props.text, { sanitize: true }))
const openExternal = (e) => {
const url = e.target?.href
if (url) {
openExternalMutation.executeMutation({ url })
}
}
</script>
+33 -11
View File
@@ -1,27 +1,49 @@
import { defaultMessages } from '@cy/i18n'
import Warning from './Warning.vue'
import faker from 'faker'
import { ref } from 'vue'
const title = faker.hacker.noun()
const message = `
# Hello!
> This is a **markdown formatted** message!
We're going to print out some \`console.log('cool code')\` and see how well it formats inside of our warning.
`
describe('<Warning />', () => {
it('renders with title and message', () => {
cy.mount(() => (<div class="p-4"><Warning
title="The title"
message="The message"
dismiss={() => {}}
data-testid="warning"
title={title}
message={message}
/></div>))
cy.contains('The title')
cy.contains('The message')
cy.contains(title)
cy.get('[data-testid=warning]')
.should('contain.text', 'Hello!')
.and('contain.text', 'This is a markdown formatted message!')
.and('contain.text', `We're going to print out some console.log('cool code') and see how well it formats inside of our warning.`)
})
it('calls dismiss when X is clicked', () => {
const onDismiss = cy.stub()
const show = ref(true)
const onUpdate = cy.spy()
const methods = {
'onUpdate:modelValue': (value) => {
show.value = value
onUpdate()
},
}
cy.mount(() => (<div class="p-4"><Warning
title="The title"
message="The message"
dismiss={onDismiss}
title={title}
message={message}
modelValue={show.value}
{...methods}
/></div>))
cy.get('[data-cy=dismiss]').click()
cy.wrap(onDismiss).should('be.called')
cy.get(`[aria-label=${defaultMessages.components.alert.dismissAriaLabel}`).first().click()
cy.wrap(onUpdate).should('be.called')
})
})
+28 -28
View File
@@ -1,38 +1,38 @@
<template>
<div
data-cy="warning"
class="rounded min-w-200px bg-warning-100 mb-5"
<Alert
v-model="show"
dismissible
status="warning"
data-testid="warning-alert"
header-class="text-warning-600"
:title="title"
:icon="ErrorOutlineIcon"
>
<h3 class="text-lg text-warning-600 font-medium flex items-center p-5 relative">
<i-cy-status-errored-outline_x16 class="w-24px h-24px mr-2" /> {{ props.title }}
<button
aria-label="Dismiss"
data-cy="dismiss"
class="text-warning-500 hocus:text-warning-600 outline-none absolute w-32px
h-32px right-8px top-8px flex items-center justify-center"
@click="onDismiss"
>
<i-cy-delete_x12 class="icon-dark-current w-12px h-12px" />
</button>
</h3>
<Markdown
class="border-t border-warning-200 text-warning-500 py-5 mt-0 mx-5"
code-class="bg-white bg-opacity-25 border-warning-300"
:text="props.message"
<div
ref="markdownTarget"
v-html="markdown"
/>
</div>
</Alert>
</template>
<script lang="ts" setup>
import Markdown from '../components/markdown/Markdown.vue'
import ErrorOutlineIcon from '~icons/cy/status-errored-outline_x16.svg'
import { useMarkdown } from '@packages/frontend-shared/src/composables/useMarkdown'
import Alert from '@cy/components/Alert.vue'
import { ref } from 'vue'
import { useVModels } from '@vueuse/core'
const props = defineProps<{
title: string,
message: string,
dismiss: Function,
const emits = defineEmits<{
(eventName: 'update:modelValue', value: boolean): void
}>()
const onDismiss = () => {
props.dismiss()
}
const props = withDefaults(defineProps<{
title: string,
message: string,
modelValue: boolean
}>(), { modelValue: true })
const { modelValue: show } = useVModels(props, emits)
const markdownTarget = ref()
const { markdown } = useMarkdown(markdownTarget, props.message, { classes: { code: ['bg-warning-200'] } })
</script>
@@ -1,12 +1,22 @@
import { WarningListFragmentDoc } from '../generated/graphql-test'
import WarningList from './WarningList.vue'
import faker from 'faker'
import { defaultMessages } from '@cy/i18n'
const createWarning = (props = {}) => Object.assign({
const warningSelector = '[data-testid=warning-alert]'
const message = faker.hacker.phrase()
const title = faker.hacker.ingverb()
const createWarning = (props = {}) => ({
__typename: 'Warning',
title: 'Warning title',
message: 'Warning message',
title,
message,
setupStep: 'welcome',
}, props)
...props,
})
const firstWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
const secondWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
describe('<WarningList />', () => {
it('does not render warning if there are none', () => {
@@ -17,7 +27,7 @@ describe('<WarningList />', () => {
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.get('[data-cy=warning]').should('not.exist')
cy.get(warningSelector).should('not.exist')
})
it('does not render warning if on different step', () => {
@@ -30,7 +40,7 @@ describe('<WarningList />', () => {
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.contains('Warning message').should('not.exist')
cy.contains(message).should('not.exist')
})
it('renders warning if on same step', () => {
@@ -45,7 +55,7 @@ describe('<WarningList />', () => {
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.contains('Warning message').should('be.visible')
cy.contains(message).should('be.visible')
})
it('renders warning if no step specified', () => {
@@ -60,7 +70,7 @@ describe('<WarningList />', () => {
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.contains('Warning message').should('be.visible')
cy.contains(message).should('be.visible')
})
it('renders multiple warnings', () => {
@@ -68,21 +78,12 @@ describe('<WarningList />', () => {
onResult (result) {
result.step = 'setupComplete'
// @ts-ignore
result.warnings = [createWarning({
title: 'Warning title 1',
message: 'Warning message 1',
setupStep: null,
// @ts-ignore
}), createWarning({
title: 'Warning title 2',
message: 'Warning message 2',
setupStep: null,
})]
result.warnings = [firstWarning, secondWarning]
},
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.get('[data-cy=warning]').should('have.length', 2)
cy.get(warningSelector).should('have.length', 2)
})
it('removes warning when dismissed', () => {
@@ -90,24 +91,17 @@ describe('<WarningList />', () => {
onResult (result) {
result.step = 'setupComplete'
// @ts-ignore
result.warnings = [createWarning({
title: 'Warning title 1',
message: 'Warning message 1',
setupStep: null,
// @ts-ignore
}), createWarning({
title: 'Warning title 2',
message: 'Warning message 2',
setupStep: null,
})]
result.warnings = [firstWarning, secondWarning]
},
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
cy.get('[data-cy=warning]').should('have.length', 2)
cy.contains('Warning message 1')
cy.get('[data-cy=dismiss]').first().click()
cy.get('[data-cy=warning]').should('have.length', 1)
cy.contains('Warning message 1').should('not.exist')
cy.get(warningSelector).should('have.length', 2)
cy.contains(firstWarning.message)
// @ts-ignore
cy.findAllByLabelText(defaultMessages.components.modal.dismiss).first().click()
cy.get(warningSelector).should('have.length', 1)
cy.contains(firstWarning.message).should('not.exist')
})
})
+17 -21
View File
@@ -1,15 +1,16 @@
<template>
<Warning
v-for="warning in warnings"
:key="warningKey(warning)"
:key="warning.key"
:title="warning.title"
:message="warning.message"
:dismiss="onDismiss(warning)"
dismissible
@update:modelValue="dismiss(warning.key)"
/>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue'
import { computed, ref } from 'vue'
import { gql } from '@urql/core'
import type { WarningListFragment } from '../generated/graphql'
import Warning from '../warning/Warning.vue'
@@ -28,25 +29,20 @@ const props = defineProps<{
gql: WarningListFragment
}>()
const dismissed = reactive({})
const warningKey = (warning) => `${warning.title}${warning.message}`
const onDismiss = (warning) => {
return () => {
dismissed[warningKey(warning)] = true
}
}
const dismissed = ref({})
const warnings = computed(() => {
return props.gql.warnings.filter((warning) => {
return (
!dismissed[warningKey(warning)]
&& (
!warning.setupStep
|| warning.setupStep === props.gql.step
)
)
return props.gql.warnings
.map((w) => ({ ...w, key: `${w.title}${w.message}` }))
.filter((warning) => {
const hasBeenDismissed = dismissed.value[warning.key]
return !hasBeenDismissed && !warning.setupStep || warning.setupStep === props.gql.step
})
})
const dismiss = (key) => {
// TODO, call a mutation here so that the server persists the result of the mutation.
// However, we still intend to keep the "warnings" dismissal so that the client updates immediately before the server responds.
dismissed.value[key] = true
}
</script>
@@ -0,0 +1,13 @@
diff --git a/node_modules/@toycode/markdown-it-class/index.js b/node_modules/@toycode/markdown-it-class/index.js
index 8e8c8f4..5e2539d 100644
--- a/node_modules/@toycode/markdown-it-class/index.js
+++ b/node_modules/@toycode/markdown-it-class/index.js
@@ -8,7 +8,7 @@ const toArray = a => (Array.isArray(a) ? a : [a])
function parseTokens(tokens) {
tokens.forEach(token => {
- if (/(_open$|image)/.test(token.type) && mapping[token.tag]) {
+ if (/(_open$|image|code_inline)/.test(token.type) && mapping[token.tag]) {
const orig = splitWithSpace(token.attrGet('class'))
const addition = toArray(mapping[token.tag])
token.attrSet('class', [...orig, ...addition].join(' '))