[deploy] Redesign article editor (#7524)

This commit is contained in:
ludwiczakpawel 2020-05-29 17:59:37 +02:00 committed by GitHub
parent e812f995fe
commit a161d3ce31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 4342 additions and 2068 deletions

1
app/assets/images/x.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" /></svg>

After

Width:  |  Height:  |  Size: 241 B

View file

@ -1,9 +1,29 @@
@import '../config/import';
:root {
--layout-site-width: var(--site-width);
--layout-header-height: var(--header-height);
--layout-sidebar-width: 200px;
--layout-aside-width: 316px;
--layout-gap: var(--su-6);
--layout-padding: var(--su-6);
--layout-article-content-padding-x: var(--su-3);
--layout-article-content-padding-y: var(--su-3);
--layout-narrow-sidebar-width: 66px;
@media (min-width: $breakpoint-s) {
--layout-article-content-padding-x: var(--su-4);
--layout-article-content-padding-y: var(--su-4);
}
@media (min-width: $breakpoint-m) {
--layout-article-content-padding-x: var(--su-6);
--layout-article-content-padding-y: var(--su-6);
}
@media (min-width: $breakpoint-l) {
--layout-article-content-padding-x: var(--su-8);
}
}
.crayons-layout {
@ -12,12 +32,6 @@
max-width: var(--site-width);
margin: 0 auto;
&__sidebar {
}
&__content {
}
&--max-l {
max-width: $breakpoint-l;
}

View file

@ -13,6 +13,7 @@
min-width: 200px;
@media (min-width: $breakpoint-s) {
max-width: 320px;
max-width: 360px;
width: max-content;
}
}

View file

@ -229,8 +229,15 @@
&--ghost:hover,
&--ghost:focus,
&--ghost[disabled] {
all: unset;
background: transparent;
border: none;
padding: 0;
margin: 0;
outline: none;
width: 100%;
box-shadow: none;
resize: none;
transition: none;
}
}

View file

@ -2,7 +2,7 @@
.crayons-modal {
--modal-max-width: 768px;
--modal-body-padding: var(--su-2);
--modal-body-padding: var(--su-3);
--modal-header-padding: var(--su-2);
@media (min-width: $breakpoint-s) {
@ -11,8 +11,12 @@
}
@media (min-width: $breakpoint-m) {
--modal-body-padding: var(--su-7);
--modal-header-padding: var(--su-4) var(--su-4) var(--su-4) var(--su-7);
--modal-body-padding: var(--su-6);
--modal-header-padding: var(--su-4) var(--su-4) var(--su-4) var(--su-6);
}
@media (min-width: $breakpoint-l) {
--modal-body-padding: var(--su-7) var(--su-8);
}
position: fixed;

View file

@ -3,7 +3,7 @@
.crayons-tabs {
// Setup
--border-width: 3px;
--tab-vertical-padding: var(--su-3);
--tab-vertical-padding: var(--su-2);
--tab-horizontal-padding: var(--su-2);
--font-size: var(--fs-base);
display: flex;
@ -31,6 +31,8 @@
border-radius: var(--radius);
transition: all var(--transition-props);
width: 100%;
border: none;
background: transparent;
@media (min-width: $breakpoint-s) {
width: auto;

View file

@ -613,7 +613,7 @@
'heavy': var(--fw-heavy)
),
(),
false
true
),
(
'list',

View file

@ -22,3 +22,6 @@
@import 'components/stories';
@import 'components/tabs';
@import 'components/tags';
@import 'views/article';
@import 'views/article-form';

View file

@ -3,7 +3,8 @@
// Alternate base fonts
body.sans-serif-article-body {
.body {
.body,
.crayons-article__body {
font-family: var(--ff-default);
}
.body p {
@ -13,7 +14,8 @@ body.sans-serif-article-body {
}
body.comic-sans-article-body {
.body {
.body,
.crayons-article__body {
font-family: 'Comic Sans MS', cursive, sans-serif;
}
.body p {
@ -23,7 +25,8 @@ body.comic-sans-article-body {
}
body.open-dyslexic-article-body {
.body {
.body,
.crayons-article__body {
font-display: swap;
font-family: OpenDyslexic, sans-serif;
}
@ -33,12 +36,16 @@ body.open-dyslexic-article-body {
font-size: 0.9em;
}
.body b,
.body strong {
.body strong,
.crayons-article__body b,
.crayons-article__body strong {
font-family: OpenDyslexic-Bold, sans-serif;
}
.body i,
.body em {
.body em,
.crayons-article__body i,
.crayons-article__body em {
font-family: OpenDyslexic-Italic, sans-serif;
}
@ -49,13 +56,22 @@ body.open-dyslexic-article-body {
.body i > b,
.body em > b,
.body i > strong,
.body em > strong {
.body em > strong,
.crayons-article__body b > i,
.crayons-article__body b > em,
.crayons-article__body strong > i,
.crayons-article__body strong > em,
.crayons-article__body i > b,
.crayons-article__body em > b,
.crayons-article__body i > strong,
.crayons-article__body em > strong {
font-family: OpenDyslexic-Bolditalic;
}
}
body.monospace-article-body {
.body {
.body,
.crayons-article__body {
font-family: var(--ff-accent);
}
.body p {

View file

@ -0,0 +1,306 @@
@import '../config/import';
%layout {
max-width: var(--layout-site-width);
width: 100%;
margin: 0 auto;
}
@mixin animationSlide($name) {
animation-name: $name;
@keyframes #{$name} {
from {
opacity: 0;
top: calc(var(--su-4) * -1);
}
to {
opacity: 1;
top: 0;
}
}
}
.crayons-article-form {
--article-form-actions-height: 56px;
--layout-padding: var(--su-2);
display: grid;
height: 100vh;
grid-template-rows: min-content 1fr min-content;
background: var(--body-bg);
font-size: var(--fs-base);
@media (min-width: $breakpoint-s) {
--article-form-actions-height: 72px;
}
@media (min-width: $breakpoint-l) {
--article-form-actions-height: 88px;
}
&__header {
@extend %layout;
display: flex;
align-items: center;
padding: 0 var(--layout-padding);
height: var(--layout-header-height);
@media (min-width: $breakpoint-l) {
padding-right: calc(
var(--layout-aside-width) + var(--layout-padding) + var(--layout-gap)
);
}
}
&__main {
@extend %layout;
@media (min-width: $breakpoint-m) {
padding: 0 var(--layout-padding);
}
@media (min-width: $breakpoint-l) {
display: grid;
grid-gap: var(--layout-gap);
grid-template-columns:
calc(100% - var(--layout-aside-width) - var(--layout-gap))
var(--layout-aside-width);
}
@media (min-width: $breakpoint-xl) {
padding-left: calc(
var(--layout-narrow-sidebar-width) + var(--layout-padding)
);
}
&__content {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--layout-article-content-padding-y)
var(--layout-article-content-padding-x);
@media (min-width: $breakpoint-s) {
overflow: auto;
height: calc(
100vh - var(--layout-header-height) -
var(--article-form-actions-height)
);
}
@media (min-width: $breakpoint-m) {
border: 1px solid var(--base-10);
}
}
&__aside {
display: none;
@media (min-width: $breakpoint-l) {
display: block;
}
}
}
&__footer {
@extend %layout;
background: var(--body-bg);
position: sticky;
bottom: 0;
padding: 0 var(--layout-padding);
height: var(--article-form-actions-height);
display: flex;
align-items: center;
@media (min-width: $breakpoint-xl) {
padding-left: calc(
var(--layout-narrow-sidebar-width) + var(--layout-padding)
);
}
}
}
.crayons-article-form {
&__logo {
display: flex;
margin-right: var(--su-1);
@media (min-width: $breakpoint-s) {
margin-right: var(--layout-gap);
}
.logo {
width: 50px;
height: 40px;
padding: 0 7px 0 9px; // TODO: we have to fix SVG for logo a little...
background: var(--logo-bg);
fill: var(--logo-color);
border-radius: var(--radius);
}
}
&__close {
margin-left: var(--su-1);
@media (min-width: $breakpoint-l) {
position: absolute;
right: var(--su-2);
top: var(--su-2);
margin-left: 0;
}
}
&__body {
flex: 1 auto;
position: relative;
outline: none;
display: flex;
flex-direction: column;
&__field {
flex: 1 auto;
font-size: var(--fs-l);
font-family: var(--ff-accent);
}
}
&__toolbar {
position: sticky;
top: 0;
background: var(--base-0);
padding: var(--su-2) var(--layout-article-content-padding-x);
margin: calc(var(--layout-article-content-padding-y) * -1)
calc(var(--layout-article-content-padding-x) * -1) var(--su-6)
calc(var(--layout-article-content-padding-x) * -1);
@media (min-width: $breakpoint-s) {
top: calc(var(--layout-article-content-padding-y) * -1);
}
> :first-child {
margin-left: calc(var(--su-2) * -1);
}
}
&__cover {
margin-bottom: var(--su-6);
display: flex;
align-items: flex-start;
flex-direction: column;
@media (min-width: $breakpoint-s) {
flex-direction: row;
align-items: center;
}
&__image {
width: 250px;
height: 105px;
border-radius: var(--radius);
object-fit: cover;
margin-bottom: var(--su-2);
@media (min-width: $breakpoint-s) {
margin-bottom: 0;
margin-right: var(--su-4);
}
}
}
&__title {
margin-bottom: var(--su-2);
position: relative;
}
&__tagsfield {
position: relative;
margin-bottom: calc(var(--su-7) + var(--layout-article-content-padding-y));
}
&__help {
position: relative;
animation-duration: 0.25s;
animation-iteration-count: 1;
animation-timing-function: var(--transition-func);
&--title {
@include animationSlide(slideInTitle);
}
&--tags {
@include animationSlide(slideInTags);
}
&--body {
@include animationSlide(slideInBody);
}
}
}
.crayons-article-form {
&__tagsoptions {
@include generate-box(
$level: 2,
$bg: var(--card-bg),
$border: var(--card-color),
$color: var(--card-color)
);
position: absolute;
left: 0;
right: 0;
top: calc(100% + var(--su-4));
z-index: 100;
padding: var(--su-1);
@media (min-width: $breakpoint-s) {
left: calc(var(--su-2) * -1);
right: calc(var(--su-2) * -1);
}
@media (min-width: $breakpoint-m) {
left: calc(var(--su-4) * -1);
right: calc(var(--su-4) * -1);
}
}
&__tagname {
font-family: var(--ff-accent);
color: var(--base-90);
letter-spacing: -0.05em;
font-weight: bold;
}
&__tagoptionrow {
cursor: pointer;
color: var(--link-color);
padding: var(--su-3);
&:hover {
background: var(--link-bg-hover);
.crayons-tag {
color: var(--link-color-hover);
}
}
&--active {
background: var(--link-bg-hover);
.crayons-article-form__tagname {
color: var(--link-color-hover);
}
}
}
&__tagsoptionrulesbutton {
display: none;
}
&__tagrules,
&__tagrules--inactive {
font-size: var(--fs-s);
color: var(--base-70);
}
&__tagsoptionsbottomrow {
display: none;
}
}

View file

@ -0,0 +1,369 @@
@import '../config/import';
:root {
--article-rythm: var(--su-2);
@media (min-width: $breakpoint-m) {
--article-rythm: var(--su-4);
}
}
.crayons-article {
&__cover {
margin: calc(var(--layout-article-content-padding-y) * -1)
calc(var(--layout-article-content-padding-x) * -1)
var(--layout-article-content-padding-x)
calc(var(--layout-article-content-padding-x) * -1);
width: calc(100% + (var(--layout-article-content-padding-x) * 2));
&__image {
border-radius: var(--radius) var(--radius) 0 0;
background-size: cover;
background-position: center center;
padding-top: 42%;
}
}
&__header {
margin-bottom: var(--su-6);
}
&__tags {
// TODO: replace with actual component one day...
font-family: var(--ff-accent);
font-size: var(--fs-s);
display: flex;
}
&__body {
font-size: var(--fs-l);
font-family: var(--ff-serif);
@media (min-width: $breakpoint-m) {
font-size: var(--fs-xl);
}
a {
code {
color: var(--link-color);
}
&.anchor {
padding-top: 0;
margin-top: 0;
top: -50px;
display: block;
visibility: hidden;
position: relative;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--ff-default);
margin: var(--article-rythm) 0;
margin-top: calc(var(--article-rythm) * 2);
padding: 0;
}
h1 {
font-size: 1.85em;
line-height: 1.25em;
font-weight: 400;
}
h2 {
font-size: 1.7em;
font-weight: 400;
line-height: 1.25em;
margin-bottom: 0.5em;
}
h3 {
font-size: 1.32em;
font-weight: 400;
}
hr {
width: 25%;
opacity: 0.1;
border: 1px solid var(--body-color);
margin: 1.3em auto 1.5em;
}
p {
margin: var(--article-rythm) 0;
}
ul,
ol {
padding-left: var(--su-6);
margin: var(--article-rythm) 0;
br {
line-height: 0;
margin: 0;
padding: 0;
}
}
ul {
list-style-type: disc;
p {
margin: 0;
padding: 0;
}
ul {
margin: 0 0.7em 0 1.9em;
list-style-type: square;
ul {
list-style-type: circle;
}
}
}
li {
margin: var(--su-2) 0;
}
figure {
margin: 0;
}
figcaption {
font-style: italic;
text-align: center;
font-size: 0.8em;
line-height: 1.4em;
color: var(--body-color);
opacity: 0.75;
display: block;
}
p + figcaption {
margin-top: -0.8em;
}
blockquote {
border-left: 4px solid var(--base-50);
padding: 0 var(--su-4);
margin: var(--article-rythm) 0;
p:first-child {
margin: var(--su-2) 0;
}
&.twitter-tweet {
font-family: var(--ff-default);
background: var(--body-color-inverted);
max-width: 456px;
border: 1px solid #e9eef2;
font-size: 0.75em;
line-height: 1.35em;
border-radius: 5px;
margin: 1.6em auto;
padding: 60px 10px 18px;
color: white;
min-height: 60px;
padding: 33px 15px 8px;
a {
color: white;
}
@media screen and (min-width: $breakpoint-s) {
padding: 60px 22px 18px;
min-height: 105px;
}
}
}
code {
margin: auto;
background: rgba(0, 0, 0, 0.1);
padding: 0.1em 0.3em 0;
border-radius: 2px;
font-size: 0.84em;
vertical-align: 0;
max-width: 100%;
line-height: 1.6em;
word-break: break-all;
}
kbd {
display: inline-block;
padding: 3px 5px;
font-size: 11px;
line-height: 10px;
color: #444d56;
vertical-align: middle;
background-color: #fafbfc;
border: solid 1px #c6cbd1;
border-bottom-color: #959da5;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #959da5;
}
pre {
background: var(--base-100);
overflow-x: auto;
color: #eff0f9;
padding: var(--su-6) var(--su-8);
margin: var(--article-rythm) 0;
margin-left: calc(var(--su-8) * -1);
margin-right: calc(var(--su-8) * -1);
width: calc(100% + var(--su-8) + var(--su-8));
overflow-wrap: normal;
border-radius: 0;
code {
background: var(--base-100);
color: #eff0f9;
white-space: pre;
}
}
img {
height: auto;
position: relative;
display: block;
margin: auto;
left: -4%;
max-width: 108%;
@media screen and (min-width: 430px) {
left: -6px;
max-width: calc(100% + 12px);
}
}
a.article-body-image-wrapper {
cursor: zoom-in;
}
iframe {
width: 100%;
border: 0;
margin: var(--article-rythm) 0;
}
.fluidvids {
margin: var(--article-rythm) 0;
iframe {
margin: 0;
}
}
.table-wrapper-paragraph {
width: 100%;
margin-left: 0%;
overflow-x: auto;
@media screen and (min-width: 430px) {
width: 108%;
margin-left: -4%;
}
@media screen and (min-width: 800px) {
width: 110%;
margin-left: -5%;
}
}
table {
border-collapse: collapse;
font-size: 0.78em;
margin: 0.8em auto 1.2em;
table-layout: fixed;
@media screen and (min-width: 500px) {
font-size: 0.9em;
}
th {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 5px 1vw;
background: var(--body-bg);
text-align: left;
}
td {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 5px 1vw;
}
}
.twitter-tweet {
margin: auto;
}
}
}
.crayons-article {
.article-collection-wrapper {
font-family: var(--ff-default);
font-size: 16px;
border-radius: var(--radius);
border: 1px solid var(--base-30);
width: 92%;
max-width: 500px;
margin: calc(1.1vw + 5px) auto 1vw;
p {
margin: 0px auto;
padding: calc(0.1vw + 9px);
font-weight: bold;
font-size: 0.96em;
}
.article-collection {
user-select: none;
font-size: 16px;
overflow: hidden;
position: relative;
z-index: 8;
a {
color: var(--body-color);
padding: calc(0.1vw + 9px);
display: block;
position: relative;
z-index: 4;
border-top: 1px solid var(--base-30);
font-size: 0.88em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:last-child {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
&.current-article {
background: var(--accent-brand-lighter);
color: var(--body-color-inverted);
}
&.coming-soon {
pointer-events: none;
color: var(--base-80);
}
&.collection-link-hidden {
display: none;
}
}
}
&.article-collection-wrapper-bottom {
margin-bottom: 3vw;
p {
margin-top: 5px;
}
}
}
}

View file

@ -2,150 +2,307 @@
exports[`<ArticleForm /> renders properly 1`] = `
<form
class="articleform__form articleform__form--v2"
class="crayons-article-form"
id="article-form"
onInput={[Function]}
>
<div>
<div>
<textarea
autoComplete="off"
class="articleform__title"
id="article-form-title"
onChange={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
placeholder="Title"
ref={[Function]}
style={
Object {
"height": 0,
}
}
type="text"
value=""
/>
<div
class="articleform__detailfields"
<div
class="crayons-article-form__header crayons-layout"
>
<a
aria-label="Home"
class="crayons-article-form__logo"
href="/"
>
<svg
aria-labelledby="aokj84btta17w7mvt93i94l82beye8ez"
class="logo"
height="20%"
role="img"
version="1"
viewBox="0 0 132.000000 65.000000"
width="20%"
xmlns="http://www.w3.org/2000/svg"
>
<div
class="articleform__tagswrapper"
<title
id="aokj84btta17w7mvt93i94l82beye8ez"
>
<input
App logo
</title>
<path
d="M0 33v32h11.3c12.5 0 17.7-1.6 21.5-6.5 3.8-4.8 4.4-9 4-28-.3-16.8-.5-18.2-2.7-21.8C30.3 2.5 26.1 1 12 1H0v32zm23.1-19.1c2.3 1.9 2.4 2.3 2.4 18.5 0 15.7-.1 16.7-2.2 18.8-1.7 1.6-3.5 2.2-7 2.2l-4.8.1-.3-20.8L11 12h4.9c3.3 0 5.6.6 7.2 1.9zM46.1 3.6c-2 2.6-2.1 3.9-2.1 29.6v26.9l2.5 2.4c2.3 2.4 2.9 2.5 16 2.5H76V54.1l-10.2-.3-10.3-.3v-15l6.3-.3 6.2-.3V27H55V12h21V1H62.1c-13.9 0-14 0-16 2.6zM87 15.2c2.1 7.9 5.5 20.8 7.6 28.8 3.2 12.3 4.3 15 7 17.7 1.9 2 4.2 3.3 5.7 3.3 3.1 0 7.1-3.1 8.5-6.7 1-2.6 15.2-55.6 15.2-56.8 0-.3-2.8-.5-6.2-.3l-6.3.3-5.6 21.5c-3.5 13.6-5.8 20.8-6.2 19.5C105.9 40 96 1.9 96 1.4c0-.2-2.9-.4-6.4-.4h-6.4L87 15.2z"
/>
</svg>
</a>
<div
class="crayons-field__label flex items-center flex-1"
>
<span
class="hidden s:inline-block whitespace-nowrap"
>
Write a new post
</span>
</div>
<div
class="crayons-article-form__tabs crayons-tabs ml-auto"
>
<button
class="crayons-tabs__item crayons-tabs__item--current"
onClick={false}
type="button"
>
Edit
</button>
<button
class="crayons-tabs__item false"
onClick={[Function]}
type="button"
>
Preview
</button>
</div>
<div
class="crayons-article-form__close"
>
<a
class="crayons-btn crayons-btn--ghost crayons-btn--icon"
href="/"
title="Close the editor"
>
<svg
aria-labelledby="as1mn15llu5e032u2pgzlc6yhvss2myk"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="as1mn15llu5e032u2pgzlc6yhvss2myk"
>
Close the editor
</title>
<path
d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z"
/>
</svg>
</a>
</div>
</div>
<div
class="crayons-article-form__main"
>
<div
class="crayons-article-form__main__content crayons-card"
>
<div>
<div
class="crayons-article-form__cover"
role="presentation"
>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--outlined mr-2 whitespace-nowrap"
disabled={false}
type="button"
>
<label
htmlFor="cover-image-input"
>
Add a cover image
</label>
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="cover-image-input"
onChange={[Function]}
type="file"
/>
</button>
</div>
</div>
<div
class="crayons-article-form__title"
>
<textarea
autoComplete="off"
class="articleform__tags"
id="tag-input"
name="listing[tag_list]"
onBlur={[Function]}
autofocus="true"
class="crayons-textfield crayons-textfield--ghost fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight"
id="article-form-title"
onChange={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
placeholder="4 tags max, comma separated, no spaces or special characters"
placeholder="New post title here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
type="text"
value=""
/>
</div>
<button
class="articleform__detailsButton articleform__detailsButton--image"
onClick={[Function]}
type="button"
<div
class="crayons-article-form__tagsfield"
>
<img
alt="Upload images"
src=""
/>
</button>
<button
class="articleform__detailsButton articleform__detailsButton--moreconfig"
onClick={[Function]}
type="button"
<div
class="crayons-article-form__tagswrapper"
>
<input
autoComplete="off"
class="crayons-textfield crayons-textfield--ghost ff-accent crayons-article-form__tags"
id="tag-input"
name="listing[tag_list]"
onBlur={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
placeholder="Add up to 4 tags..."
ref={[Function]}
type="text"
value=""
/>
</div>
</div>
</div>
<div
class="crayons-article-form__body"
>
<div
class="crayons-article-form__toolbar false"
>
<img
alt="Menu"
src=""
/>
</button>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--ghost crayons-btn--icon-left mr-2 fw-normal"
disabled={false}
tabIndex="-1"
type="button"
>
<svg
aria-labelledby="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
>
Upload image
</title>
<path
d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"
/>
</svg>
Upload image
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="image-upload-field"
multiple={true}
onChange={[Function]}
tabIndex="-1"
type="file"
/>
</button>
</div>
</div>
<textarea
class="crayons-textfield crayons-textfield--ghost crayons-article-form__body__field"
id="article_body_markdown"
name="body_markdown"
onChange={[Function]}
onFocus={[Function]}
onInput={[Function]}
placeholder="Write your post content here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
value=""
/>
</div>
</div>
<textarea
class="articleform__body"
id="article_body_markdown"
name="body_markdown"
onInput={[Function]}
placeholder="Body Markdown"
value=""
/>
<button
class="articleform__detailsButton articleform__detailsButton--image articleform__detailsButton--bottom"
onClick={[Function]}
type="button"
<div
class="crayons-article-form__main__aside"
>
<img
alt="upload images"
src=""
/>
IMAGES
</button>
<button
class="articleform__detailsButton articleform__detailsButton--moreconfig articleform__detailsButton--bottom"
onClick={[Function]}
type="button"
>
<img
alt="menu dots"
src=""
/>
</button>
</div>
<div
class="articleform__buttons"
>
<button
class="articleform__buttons--small inactive"
onClick={[Function]}
type="button"
>
?
</button>
<button
class="inactive previewbutt"
onClick={[Function]}
type="button"
>
PREVIEW
</button>
<button
onClick={[Function]}
type="button"
>
SAVE DRAFT
</button>
<span>
<p
<div
class="sticky"
style={
Object {
"visibility": "hidden",
"top": null,
}
}
/>
</div>
</div>
<div
class="crayons-article-form__footer"
>
<button
class="crayons-btn mr-2 whitespace-nowrap"
disabled={false}
onClick={[Function]}
type="button"
>
Publish
</button>
<button
class="crayons-btn crayons-btn--secondary mr-2 whitespace-nowrap"
disabled={false}
onClick={[Function]}
type="button"
>
Save
<span
class="hidden s:inline"
>
New Changes (
<button
class="clear-button"
onClick={[Function]}
type="button"
>
clear
</button>
)
</p>
draft
</span>
</button>
<div
class="s:relative"
>
<button
class="articleform__buttons--publish"
class="crayons-btn crayons-btn--ghost crayons-btn--icon"
disabled={false}
onClick={[Function]}
title="Post options"
type="button"
>
PUBLISH
<svg
aria-labelledby="75abcb76478519ca4eb9"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="75abcb76478519ca4eb9"
>
Post options
</title>
<path
d="M12 1l9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 110-8 4 4 0 010 8zm0-2a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
</span>
</div>
</div>
</form>
`;

View file

@ -80,16 +80,4 @@ describe('<ArticleForm />', () => {
expect(form.state().bodyMarkdown).toBe('');
});
it('toggles help on help button press', () => {
const form = deep(getArticleForm());
global.scrollTo = jest.fn();
form
.find('.articleform__buttons--small')
.simulate('click', { preventDefault: () => {} });
expect(form.state().helpShowing).toBe(true);
form
.find('.articleform__buttons--small')
.simulate('click', { preventDefault: () => {} });
expect(form.state().helpShowing).toBe(false);
});
});

View file

@ -11,7 +11,7 @@ export function previewArticle(payload, successCb, failureCb) {
}),
credentials: 'same-origin',
})
.then(response => response.json())
.then((response) => response.json())
.then(successCb)
.catch(failureCb);
}
@ -20,6 +20,7 @@ export function getArticle() {}
function processPayload(payload) {
const {
/* eslint-disable no-unused-vars */
previewShowing,
helpShowing,
previewResponse,
@ -27,6 +28,7 @@ function processPayload(payload) {
imageManagementShowing,
moreConfigShowing,
errors,
/* eslint-enable no-unused-vars */
...neededPayload
} = payload;
return neededPayload;
@ -47,13 +49,14 @@ export function submitArticle(payload, clearStorage, errorCb, failureCb) {
}),
credentials: 'same-origin',
})
.then(response => response.json())
.then(response => {
.then((response) => response.json())
.then((response) => {
if (response.current_state_path) {
clearStorage();
window.location.replace(response.current_state_path);
} else {
errorCb(response);
// If there is an error and the method is POST, we know they are trying to publish.
errorCb(response, method === 'POST');
}
})
.catch(failureCb);
@ -83,8 +86,8 @@ export function generateMainImage(payload, successCb, failureCb) {
body: generateUploadFormdata(payload),
credentials: 'same-origin',
})
.then(response => response.json())
.then(json => {
.then((response) => response.json())
.then((json) => {
if (json.error) {
throw new Error(json.error);
}

View file

@ -1,45 +1,19 @@
import 'preact/devtools';
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import linkState from 'linkstate';
import postscribe from 'postscribe';
// eslint-disable-next-line import/no-unresolved
import ImageUploadIcon from 'images/image-upload.svg';
// eslint-disable-next-line import/no-unresolved
import ThreeDotsIcon from 'images/three-dots.svg';
import { submitArticle, previewArticle } from './actions';
import BodyMarkdown from './elements/bodyMarkdown';
import BodyPreview from './elements/bodyPreview';
import PublishToggle from './elements/publishToggle';
import Notice from './elements/notice';
import Title from './elements/title';
import MainImage from './elements/mainImage';
import ImageManagement from './elements/imageManagement';
import MoreConfig from './elements/moreConfig';
import Errors from './elements/errors';
import KeyboardShortcutsHandler from './elements/keyboardShortcutsHandler';
import Tags from '../shared/components/tags';
import { OrganizationPicker } from '../organization/OrganizationPicker';
/* global activateRunkitTags */
const SetupImageButton = ({
className,
imgSrc,
imgAltText,
onClickCallback,
}) => (
<button type="button" className={className} onClick={onClickCallback}>
<img src={imgSrc} alt={imgAltText} />
</button>
);
SetupImageButton.propTypes = {
className: PropTypes.string.isRequired,
imgSrc: PropTypes.string.isRequired,
imgAltText: PropTypes.string.isRequired,
onClickCallback: PropTypes.func.isRequired,
};
import {
EditorActions,
Form,
Header,
Help,
Preview,
KeyboardShortcutsHandler,
} from './components';
/*
Although the state fields: id, description, canonicalUrl, series, allSeries and
@ -87,13 +61,9 @@ export default class ArticleForm extends Component {
bodyMarkdown: this.article.body_markdown || '',
published: this.article.published || false,
previewShowing: false,
helpShowing: false,
previewResponse: '',
helpHTML: document.getElementById('editor-help-guide').innerHTML,
submitting: false,
editing: this.article.id !== null, // eslint-disable-line react/no-unused-state
imageManagementShowing: false,
moreConfigShowing: false,
mainImage: this.article.main_image || null,
organizations,
organizationId: this.article.organization_id,
@ -101,6 +71,8 @@ export default class ArticleForm extends Component {
edited: false,
updatedAt: this.article.updated_at,
version,
helpFor: null,
helpPosition: null,
};
}
@ -150,28 +122,17 @@ export default class ArticleForm extends Component {
};
setCommonProps = ({
helpShowing = false,
previewShowing = false,
imageManagementShowing = false,
moreConfigShowing = false,
helpFor = null,
helpPosition = null,
}) => {
return {
helpShowing,
previewShowing,
imageManagementShowing,
moreConfigShowing,
helpFor,
helpPosition,
};
};
toggleHelp = (e) => {
const { helpShowing } = this.state;
e.preventDefault();
window.scrollTo(0, 0);
this.setState({
...this.setCommonProps({ helpShowing: !helpShowing }),
});
};
fetchPreview = (e) => {
const { previewShowing, bodyMarkdown } = this.state;
e.preventDefault();
@ -184,25 +145,6 @@ export default class ArticleForm extends Component {
}
};
toggleImageManagement = (e) => {
const { imageManagementShowing } = this.state;
e.preventDefault();
window.scrollTo(0, 0);
this.setState({
...this.setCommonProps({
imageManagementShowing: !imageManagementShowing,
}),
});
};
toggleMoreConfig = (e) => {
const { moreConfigShowing } = this.state;
e.preventDefault();
this.setState({
...this.setCommonProps({ moreConfigShowing: !moreConfigShowing }),
});
};
showPreview = (response) => {
if (response.processed_html) {
this.setState({
@ -239,7 +181,6 @@ export default class ArticleForm extends Component {
handleMainImageUrlChange = (payload) => {
this.setState({
mainImage: payload.links[0],
imageManagementShowing: false,
});
};
@ -265,14 +206,6 @@ export default class ArticleForm extends Component {
submitArticle(state, this.removeLocalStorage, this.handleArticleError);
};
handleTitleKeyDown = (e) => {
if (e.keyCode === 13) {
e.preventDefault();
}
};
handleBodyKeyDown = (_e) => {};
onClearChanges = (e) => {
e.preventDefault();
// eslint-disable-next-line no-alert
@ -291,24 +224,24 @@ export default class ArticleForm extends Component {
bodyMarkdown: this.article.body_markdown || '',
published: this.article.published || false,
previewShowing: false,
helpShowing: false,
previewResponse: '',
helpHTML: document.getElementById('editor-help-guide').innerHTML,
submitting: false,
editing: this.article.id !== null, // eslint-disable-line react/no-unused-state
imageManagementShowing: false,
moreConfigShowing: false,
mainImage: this.article.main_image || null,
errors: null,
edited: false,
helpFor: null,
helpPosition: 0,
});
};
handleArticleError = (response) => {
handleArticleError = (response, publishFailed = false) => {
window.scrollTo(0, 0);
this.setState({
errors: response,
submitting: false,
// Even if it's an update that failed, published will still be set to true
published: !publishFailed,
});
};
@ -321,185 +254,94 @@ export default class ArticleForm extends Component {
});
};
switchHelpContext = ({ target }) => {
this.setState({
...this.setCommonProps({
helpFor: target.id,
helpPosition: target.getBoundingClientRect().y,
}),
});
};
render() {
// cover image url should asking for url OR providing option to upload an image
const {
title,
tagList,
bodyMarkdown,
published,
previewShowing,
helpShowing,
previewResponse,
helpHTML,
submitting,
imageManagementShowing,
moreConfigShowing,
organizations,
organizationId,
mainImage,
errors,
edited,
version,
helpFor,
helpPosition,
} = this.state;
const notice = submitting ? (
<Notice published={published} version={version} />
) : (
''
);
const imageArea =
mainImage && !previewShowing && version === 'v2' ? (
<MainImage mainImage={mainImage} onEdit={this.toggleImageManagement} />
) : (
''
);
const imageManagement = imageManagementShowing ? (
<ImageManagement
onExit={this.toggleImageManagement}
mainImage={mainImage}
version={version}
onMainImageUrlChange={this.handleMainImageUrlChange}
/>
) : (
''
);
const moreConfig = moreConfigShowing ? (
<MoreConfig
onExit={this.toggleMoreConfig}
passedData={this.state}
onSaveDraft={this.onSaveDraft}
onConfigChange={this.handleConfigChange}
/>
) : (
''
);
const orgArea =
organizations && organizations.length > 0 ? (
<div className="articleform__orgsettings">
Publish under an organization:
<OrganizationPicker
name="article[organization_id]"
id="article_publish_under_org"
organizations={organizations}
organizationId={organizationId}
onToggle={this.handleOrgIdChange}
/>
</div>
) : null;
const errorsArea = errors ? <Errors errorsList={errors} /> : '';
let editorView = '';
if (previewShowing) {
editorView = (
<div>
{errorsArea}
{orgArea}
{imageArea}
<BodyPreview
previewResponse={previewResponse}
articleState={this.state}
version="article-preview"
/>
</div>
);
} else if (helpShowing) {
editorView = (
<BodyPreview
previewResponse={{ processed_html: helpHTML }}
version="help"
/>
);
} else {
let controls = '';
let moreConfigBottomButton = '';
if (version === 'v2') {
moreConfigBottomButton = (
<SetupImageButton
className="articleform__detailsButton articleform__detailsButton--moreconfig articleform__detailsButton--bottom"
imgSrc={ThreeDotsIcon}
imgAltText="menu dots"
onClickCallback={this.toggleMoreConfig}
/>
);
controls = (
<div
className={title.length > 128 ? 'articleform__titleTooLong' : ''}
>
<Title
defaultValue={title}
onKeyDown={this.handleTitleKeyDown}
onChange={linkState(this, 'title')}
/>
<div className="articleform__detailfields">
<Tags
defaultValue={tagList}
onInput={linkState(this, 'tagList')}
maxTags={4}
autoComplete="off"
classPrefix="articleform"
/>
<SetupImageButton
className="articleform__detailsButton articleform__detailsButton--image"
imgSrc={ImageUploadIcon}
imgAltText="Upload images"
onClickCallback={this.toggleImageManagement}
/>
<SetupImageButton
className="articleform__detailsButton articleform__detailsButton--moreconfig"
imgSrc={ThreeDotsIcon}
imgAltText="Menu"
onClickCallback={this.toggleMoreConfig}
/>
</div>
</div>
);
}
editorView = (
<div>
{errorsArea}
{orgArea}
{imageArea}
{controls}
<BodyMarkdown
defaultValue={bodyMarkdown}
onKeyDown={this.handleBodyKeyDown}
onChange={linkState(this, 'bodyMarkdown')}
/>
<button
className="articleform__detailsButton articleform__detailsButton--image articleform__detailsButton--bottom"
onClick={this.toggleImageManagement}
type="button"
>
<img src={ImageUploadIcon} alt="upload images" />
IMAGES
</button>
{moreConfigBottomButton}
</div>
);
}
return (
<form
className={`articleform__form articleform__form--${version}`}
id="article-form"
className="crayons-article-form"
onSubmit={this.onSubmit}
onInput={this.toggleEdit}
>
{editorView}
<PublishToggle
<Header
onPreview={this.fetchPreview}
previewShowing={previewShowing}
organizations={organizations}
organizationId={organizationId}
onToggle={this.handleOrgIdChange}
/>
<div className="crayons-article-form__main">
{previewShowing ? (
<Preview
previewResponse={previewResponse}
articleState={this.state}
errors={errors}
/>
) : (
<Form
titleDefaultValue={title}
titleOnChange={linkState(this, 'title')}
tagsDefaultValue={tagList}
tagsOnInput={linkState(this, 'tagList')}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={linkState(this, 'bodyMarkdown')}
bodyHasFocus={false}
version={version}
mainImage={mainImage}
onMainImageUrlChange={this.handleMainImageUrlChange}
errors={errors}
switchHelpContext={this.switchHelpContext}
/>
)}
<Help
previewShowing={previewShowing}
helpFor={helpFor}
helpPosition={helpPosition}
version={version}
/>
</div>
<EditorActions
published={published}
version={version}
previewShowing={previewShowing}
helpShowing={helpShowing}
onPreview={this.fetchPreview}
onPublish={this.onPublish}
onHelp={this.toggleHelp}
onSaveDraft={this.onSaveDraft}
onClearChanges={this.onClearChanges}
edited={edited}
onChange={linkState(this, 'published')}
passedData={this.state}
onConfigChange={this.handleConfigChange}
submitting={submitting}
/>
<KeyboardShortcutsHandler togglePreview={this.fetchPreview} />
{notice}
{imageManagement}
{moreConfig}
</form>
);
}

View file

@ -0,0 +1,101 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
import { generateMainImage } from '../actions';
import { validateFileInputs } from '../../packs/validateFileInputs';
export class ArticleCoverImage extends Component {
constructor(props) {
super(props);
this.state = {
uploadError: false,
uploadErrorMessage: null,
};
}
handleMainImageUpload = (e) => {
e.preventDefault();
this.clearUploadError();
const validFileInputs = validateFileInputs();
if (validFileInputs) {
const payload = { image: e.target.files, wrap_cloudinary: true };
const { onMainImageUrlChange } = this.props;
generateMainImage(payload, onMainImageUrlChange, this.onUploadError);
}
};
clearUploadError = () => {
this.setState({
uploadError: false,
uploadErrorMessage: null,
});
};
onUploadError = (error) => {
this.setState({
uploadError: true,
uploadErrorMessage: error.message,
});
};
triggerMainImageRemoval = (e) => {
e.preventDefault();
const { onMainImageUrlChange } = this.props;
onMainImageUrlChange({
links: [],
});
};
render() {
const { mainImage } = this.props;
const { uploadError, uploadErrorMessage } = this.state;
const uploadLabel = mainImage ? 'Change' : 'Add a cover image';
return (
<div className="crayons-article-form__cover" role="presentation">
{mainImage && (
<img
src={mainImage}
className="crayons-article-form__cover__image"
width="250"
height="105"
alt="Post cover"
/>
)}
<div className="flex items-center">
<Button variant="outlined" className="mr-2 whitespace-nowrap">
<label htmlFor="cover-image-input">{uploadLabel}</label>
<input
id="cover-image-input"
type="file"
onChange={this.handleMainImageUpload}
accept="image/*"
className="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
/>
</Button>
{mainImage && (
<Button
variant="ghost-danger"
onClick={this.triggerMainImageRemoval}
>
Remove
</Button>
)}
</div>
{uploadError && (
<p className="articleform__uploaderror">{uploadErrorMessage}</p>
)}
</div>
);
}
}
ArticleCoverImage.propTypes = {
mainImage: PropTypes.string.isRequired,
onMainImageUrlChange: PropTypes.func.isRequired,
};
ArticleCoverImage.displayName = 'ArticleCoverImage';

View file

@ -0,0 +1,67 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
function linksToMarkdownForm(imageLinks) {
return imageLinks.map((imageLink) => `![Alt Text](${imageLink})`).join('\n');
}
const CopyIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="fc5f15add1e114844f5e"
>
<title id="fc5f15add1e114844f5e">Copy Markdown for image</title>
<path d="M7 6V3a1 1 0 011-1h12a1 1 0 011 1v14a1 1 0 01-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 013 21l.003-14c0-.552.45-1 1.007-1H7zm2 0h8v10h2V4H9v2zm-2 5v2h6v-2H7zm0 4v2h6v-2H7z" />
</svg>
);
CopyIcon.displayName = 'CopyIcon';
export const ClipboardButton = ({
onCopy,
imageUrls,
showCopyMessage = false,
}) => (
<clipboard-copy
onClick={onCopy}
for="image-markdown-copy-link-input"
aria-live="polite"
className="flex items-center flex-1"
aria-controls="image-markdown-copy-link-announcer"
>
<input
type="text"
className="crayons-textfield mr-2"
id="image-markdown-copy-link-input"
readOnly="true"
value={linksToMarkdownForm(imageUrls)}
/>
<Button
className="spec__image-markdown-copy"
variant="ghost"
contentType="icon"
icon={CopyIcon}
/>
<span
id="image-markdown-copy-link-announcer"
role="alert"
className={`fs-s ${showCopyMessage ? '' : 'opacity-0'}`}
>
Copied!
</span>
</clipboard-copy>
);
ClipboardButton.displayName = 'ClipboardButton';
ClipboardButton.propTypes = {
onCopy: PropTypes.func.isRequired,
imageUrls: PropTypes.arrayOf(PropTypes.string).isRequired,
showCopyMessage: PropTypes.bool.isRequired,
};

View file

@ -0,0 +1,34 @@
import { h } from 'preact';
import { Button } from '@crayons';
export const Close = () => {
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="as1mn15llu5e032u2pgzlc6yhvss2myk"
>
<title id="as1mn15llu5e032u2pgzlc6yhvss2myk">Close the editor</title>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
return (
<div className="crayons-article-form__close">
<Button
variant="ghost"
contentType="icon"
url="/"
tagName="a"
icon={Icon}
title="Close the editor"
/>
</div>
);
};
Close.displayName = 'Close';

View file

@ -0,0 +1,134 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
import { Options } from './Options';
const Icon = () => (
<svg
width="24"
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="75abcb76478519ca4eb9"
>
<title id="75abcb76478519ca4eb9">Post options</title>
<path d="M12 1l9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 110-8 4 4 0 010 8zm0-2a2 2 0 100-4 2 2 0 000 4z" />
</svg>
);
export class EditorActions extends Component {
constructor(props) {
super(props);
this.state = {
moreConfigShowing: false,
};
}
setCommonProps = ({ moreConfigShowing = false }) => {
return {
moreConfigShowing,
};
};
toggleMoreConfig = (e) => {
const { moreConfigShowing } = this.state;
e.preventDefault();
this.setState({
...this.setCommonProps({ moreConfigShowing: !moreConfigShowing }),
});
};
render() {
const {
onSaveDraft,
onPublish,
onClearChanges,
published,
edited,
version,
passedData,
onConfigChange,
submitting,
} = this.props;
const { moreConfigShowing } = this.state;
return submitting ? (
<div className="crayons-article-form__footer">
<Button className="mr-2 whitespace-nowrap" onClick={onPublish} disabled>
{published && version === 'v2'
? 'Publishing...'
: `Saving ${version === 'v2' ? 'draft' : ''}...`}
</Button>
</div>
) : (
<div className="crayons-article-form__footer">
<Button className="mr-2 whitespace-nowrap" onClick={onPublish}>
{published || version === 'v1' ? 'Save changes' : 'Publish'}
</Button>
{published || version === 'v1' ? (
''
) : (
<Button
variant="secondary"
className="mr-2 whitespace-nowrap"
onClick={onSaveDraft}
>
Save
{' '}
<span className="hidden s:inline">draft</span>
</Button>
)}
{version === 'v2' && (
<div className="s:relative">
<Button
variant="ghost"
contentType="icon"
icon={Icon}
title="Post options"
onClick={this.toggleMoreConfig}
/>
{moreConfigShowing && (
<Options
passedData={passedData}
onConfigChange={onConfigChange}
onSaveDraft={onSaveDraft}
moreConfigShowing={moreConfigShowing}
toggleMoreConfig={this.toggleMoreConfig}
/>
)}
</div>
)}
{edited && (
<Button
variant="ghost"
onClick={onClearChanges}
className="whitespace-nowrap fw-normal"
size="s"
>
Revert
{' '}
<span className="hidden s:inline">new changes</span>
</Button>
)}
</div>
);
}
}
EditorActions.propTypes = {
onSaveDraft: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
published: PropTypes.bool.isRequired,
edited: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
onClearChanges: PropTypes.func.isRequired,
passedData: PropTypes.string.isRequired,
onConfigChange: PropTypes.func.isRequired,
submitting: PropTypes.bool.isRequired,
};
EditorActions.displayName = 'EditorActions';

View file

@ -0,0 +1,38 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import Textarea from 'preact-textarea-autosize';
import { Toolbar } from './Toolbar';
export const EditorBody = ({
onChange,
defaultValue,
switchHelpContext,
version,
}) => {
return (
<div className="crayons-article-form__body">
<Toolbar version={version} />
<Textarea
className="crayons-textfield crayons-textfield--ghost crayons-article-form__body__field"
id="article_body_markdown"
placeholder="Write your post content here..."
value={defaultValue}
onInput={onChange}
onFocus={(_event) => {
switchHelpContext(_event);
}}
name="body_markdown"
/>
</div>
);
};
EditorBody.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
switchHelpContext: PropTypes.func.isRequired,
version: PropTypes.string.isRequired,
};
EditorBody.displayName = 'EditorBody';

View file

@ -0,0 +1,27 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
export const ErrorList = ({ errors }) => {
return (
<div className="crayons-notice crayons-notice--danger mb-6">
<h3 className="fs-l mb-2 fw-bold">Whoops, something went wrong:</h3>
<ul className="list-disc pl-6">
{Object.keys(errors).map((key) => {
return (
<li>
{key}
{`: `}
{errors[key]}
</li>
);
})}
</ul>
</div>
);
};
ErrorList.propTypes = {
errors: PropTypes.objectOf(PropTypes.string).isRequired,
};
ErrorList.displayName = 'ErrorList';

View file

@ -0,0 +1,63 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { EditorBody } from './EditorBody';
import { Meta } from './Meta';
import { ErrorList } from './ErrorList';
export const Form = ({
titleDefaultValue,
titleOnChange,
tagsDefaultValue,
tagsOnInput,
bodyDefaultValue,
bodyOnChange,
bodyHasFocus,
version,
mainImage,
onMainImageUrlChange,
switchHelpContext,
errors,
}) => {
return (
<div className="crayons-article-form__main__content crayons-card">
{errors && <ErrorList errors={errors} />}
{version === 'v2' && (
<Meta
titleDefaultValue={titleDefaultValue}
titleOnChange={titleOnChange}
tagsDefaultValue={tagsDefaultValue}
tagsOnInput={tagsOnInput}
mainImage={mainImage}
onMainImageUrlChange={onMainImageUrlChange}
switchHelpContext={switchHelpContext}
/>
)}
<EditorBody
defaultValue={bodyDefaultValue}
onChange={bodyOnChange}
hasFocus={bodyHasFocus}
switchHelpContext={switchHelpContext}
version={version}
/>
</div>
);
};
Form.propTypes = {
titleDefaultValue: PropTypes.string.isRequired,
titleOnChange: PropTypes.func.isRequired,
tagsDefaultValue: PropTypes.string.isRequired,
tagsOnInput: PropTypes.func.isRequired,
bodyDefaultValue: PropTypes.string.isRequired,
bodyOnChange: PropTypes.func.isRequired,
bodyHasFocus: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
mainImage: PropTypes.string.isRequired,
onMainImageUrlChange: PropTypes.func.isRequired,
switchHelpContext: PropTypes.func.isRequired,
errors: PropTypes.func.isRequired,
};
Form.displayName = 'Form';

View file

@ -0,0 +1,47 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Close } from './Close';
import { Tabs } from './Tabs';
import { PageTitle } from './PageTitle';
export const Header = ({onPreview, previewShowing, organizations, organizationId, onToggle}) => {
return (
<div className="crayons-article-form__header crayons-layout">
<a href="/" className="crayons-article-form__logo" aria-label="Home">
<svg
xmlns="http://www.w3.org/2000/svg"
version="1"
width="20%"
height="20%"
viewBox="0 0 132.000000 65.000000"
className="logo"
role="img"
aria-labelledby="aokj84btta17w7mvt93i94l82beye8ez"
>
<title id="aokj84btta17w7mvt93i94l82beye8ez">App logo</title>
<path d="M0 33v32h11.3c12.5 0 17.7-1.6 21.5-6.5 3.8-4.8 4.4-9 4-28-.3-16.8-.5-18.2-2.7-21.8C30.3 2.5 26.1 1 12 1H0v32zm23.1-19.1c2.3 1.9 2.4 2.3 2.4 18.5 0 15.7-.1 16.7-2.2 18.8-1.7 1.6-3.5 2.2-7 2.2l-4.8.1-.3-20.8L11 12h4.9c3.3 0 5.6.6 7.2 1.9zM46.1 3.6c-2 2.6-2.1 3.9-2.1 29.6v26.9l2.5 2.4c2.3 2.4 2.9 2.5 16 2.5H76V54.1l-10.2-.3-10.3-.3v-15l6.3-.3 6.2-.3V27H55V12h21V1H62.1c-13.9 0-14 0-16 2.6zM87 15.2c2.1 7.9 5.5 20.8 7.6 28.8 3.2 12.3 4.3 15 7 17.7 1.9 2 4.2 3.3 5.7 3.3 3.1 0 7.1-3.1 8.5-6.7 1-2.6 15.2-55.6 15.2-56.8 0-.3-2.8-.5-6.2-.3l-6.3.3-5.6 21.5c-3.5 13.6-5.8 20.8-6.2 19.5C105.9 40 96 1.9 96 1.4c0-.2-2.9-.4-6.4-.4h-6.4L87 15.2z"></path>
</svg>
</a>
<PageTitle
organizations={organizations}
organizationId={organizationId}
onToggle={onToggle}
/>
<Tabs
onPreview={onPreview}
previewShowing={previewShowing}
/>
<Close />
</div>
);
};
Header.propTypes = {
onPreview: PropTypes.func.isRequired,
previewShowing: PropTypes.bool.isRequired,
organizations: PropTypes.string.isRequired,
organizationId: PropTypes.string.isRequired,
onToggle: PropTypes.string.isRequired,
};
Header.displayName = 'Header';

View file

@ -0,0 +1,220 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { Modal } from '@crayons';
export class Help extends Component {
constructor(props) {
super(props);
this.state = {
liquidHelpHTML:
document.getElementById('editor-liquid-help') &&
document.getElementById('editor-liquid-help').innerHTML,
markdownHelpHTML:
document.getElementById('editor-markdown-help') &&
document.getElementById('editor-markdown-help').innerHTML,
frontmatterHelpHTML:
document.getElementById('editor-frontmatter-help') &&
document.getElementById('editor-frontmatter-help').innerHTML,
};
}
setCommonProps = ({
liquidShowing = false,
markdownShowing = false,
frontmatterShowing = false,
}) => {
return {
liquidShowing,
markdownShowing,
frontmatterShowing,
};
};
toggleModal = (varShowing) => (e) => {
e.preventDefault();
this.setState((prevState) => ({
...this.setCommonProps({
[varShowing]: !prevState[varShowing],
}),
}));
};
renderArticleFormTitleHelp = () => {
return (
<div className="spec__title-help crayons-article-form__help crayons-article-form__help--title">
<h4 className="mb-2 fs-l">Writing a Great Post Title</h4>
<ul className="list-disc pl-6 color-base-70">
<li>
Think of your post title as a super short (but compelling!)
description like an overview of the actual post in one short
sentence.
</li>
<li>
Use keywords where appropriate to help ensure people can find your
post by search.
</li>
</ul>
</div>
);
};
renderTagInputHelp = () => {
return (
<div className="spec__basic-tag-input-help crayons-article-form__help crayons-article-form__help--tags">
<h4 className="mb-2 fs-l">Tagging Guidelines</h4>
<ul className="list-disc pl-6 color-base-70">
<li>Tags will help the right people find your post.</li>
<li>
Think of tags as the topics or categories that best describe your
post.
</li>
<li>
Add up to four tags per post. Combine tags to reach the appropriate
subcommunities.
</li>
<li>Try to use existing tags whenever possible.</li>
<li>
Some tags, such as help or healthydebate, have special posting
guidelines.
</li>
</ul>
</div>
);
};
renderBasicEditorHelp = () => {
return (
<div className="spec__basic-editor-help crayons-card crayons-card--secondary p-4 mb-6">
You are currently using the basic markdown editor that uses
{' '}
<a href="#frontmatter" onClick={this.toggleModal('frontmatterShowing')}>
Jekyll front matter
</a>
. You can also use the
{' '}
<em>rich+markdown</em>
{' '}
editor you can find in
{' '}
<a href="/settings/ux">
UX settings
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="c038a36b2512ed25db907e179ab45cfc"
>
<title id="c038a36b2512ed25db907e179ab45cfc">
Open UX settings
</title>
<path d="M10.667 8v1.333H7.333v7.334h7.334v-3.334H16v4a.666.666 0 01-.667.667H6.667A.666.666 0 016 17.333V8.667A.667.667 0 016.667 8h4zM18 6v5.333h-1.333V8.275l-5.196 5.196-.942-.942 5.194-5.196h-3.056V6H18z" />
</svg>
</a>
.
</div>
);
};
renderFormatHelp = () => {
return (
<div className="spec__format-help crayons-article-form__help crayons-article-form__help--body">
<h4 className="mb-2 fs-l">Editor Basics</h4>
<ul className="list-disc pl-6 color-base-70">
<li>
Use
{' '}
<a href="#markdown" onClick={this.toggleModal('markdownShowing')}>
Markdown
</a>
{' '}
to write and format posts.
</li>
<li>
You can use
{' '}
<a href="#liquid" onClick={this.toggleModal('liquidShowing')}>
Liquid tags
</a>
{' '}
to add rich content such as Tweets, YouTube videos, etc.
</li>
</ul>
</div>
);
};
renderModal = (onClose, title, helpHtml) => {
return (
<Modal onClose={onClose} title={title}>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: helpHtml }}
/>
</Modal>
);
};
render() {
const { previewShowing, helpFor, helpPosition, version } = this.props;
const {
liquidHelpHTML,
markdownHelpHTML,
frontmatterHelpHTML,
liquidShowing,
markdownShowing,
frontmatterShowing,
} = this.state;
return (
<div className="crayons-article-form__main__aside">
{!previewShowing && (
<div
className="sticky"
style={{ top: version === 'v1' ? '56px' : helpPosition }}
>
{helpFor === 'article-form-title' &&
this.renderArticleFormTitleHelp()}
{helpFor === 'tag-input' && this.renderTagInputHelp()}
{version === 'v1' && this.renderBasicEditorHelp()}
{(helpFor === 'article_body_markdown' || version === 'v1') &&
this.renderFormatHelp()}
</div>
)}
{liquidShowing &&
this.renderModal(
this.toggleModal('liquidShowing'),
'🌊 Liquid Tags',
liquidHelpHTML,
)}
{markdownShowing &&
this.renderModal(
this.toggleModal('markdownShowing'),
'✍️ Markdown',
markdownHelpHTML,
)}
{frontmatterShowing &&
this.renderModal(
this.toggleModal('frontmatterShowing'),
'Jekyll Front Matter',
frontmatterHelpHTML,
)}
</div>
);
}
}
Help.propTypes = {
previewShowing: PropTypes.bool.isRequired,
helpFor: PropTypes.string.isRequired,
helpPosition: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
};
Help.displayName = 'Help';

View file

@ -0,0 +1,163 @@
import { h, Component } from 'preact';
import { Button } from '@crayons';
import { generateMainImage } from '../actions';
import { validateFileInputs } from '../../packs/validateFileInputs';
import { ClipboardButton } from './ClipboardButton';
function isNativeAndroid() {
return (
navigator.userAgent === 'DEV-Native-android' &&
typeof AndroidBridge !== 'undefined' &&
AndroidBridge !== null
);
}
function isClipboardSupported() {
return (
typeof navigator.clipboard !== 'undefined' && navigator.clipboard !== null
);
}
const ImageIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
>
<title id="a17qec5pfhrwzk9w4kg0tp62v27qqu9t">Upload image</title>
<path d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z" />
</svg>
);
ImageIcon.displayName = 'ImageIcon';
export class ImageUploader extends Component {
state = {
insertionImageUrls: [],
uploadError: false,
uploadErrorMessage: null,
showImageCopiedMessage: false,
};
onUploadError = (error) => {
this.setState({
insertionImageUrls: [],
uploadError: true,
uploadErrorMessage: error.message,
});
};
copyText = () => {
this.imageMarkdownInput = document.getElementById(
'image-markdown-copy-link-input',
);
if (isNativeAndroid()) {
AndroidBridge.copyToClipboard(this.imageMarkdownInput.value);
this.setState({ showImageCopiedMessage: true });
} else if (isClipboardSupported()) {
navigator.clipboard
.writeText(this.imageMarkdownInput.value)
.then(() => {
this.setState({ showImageCopiedMessage: true });
})
.catch((_err) => {
this.execCopyText();
});
} else {
this.execCopyText();
}
};
handleInsertionImageUpload = (e) => {
const { files } = e.target;
this.clearUploadError();
const validFileInputs = validateFileInputs();
if (validFileInputs && files.length > 0) {
const payload = { image: files };
this.setState({ showImageCopiedMessage: false });
generateMainImage(
payload,
this.handleInsertImageUploadSuccess,
this.onUploadError,
);
}
};
handleInsertImageUploadSuccess = (response) => {
this.setState({
insertionImageUrls: response.links,
});
};
execCopyText() {
this.imageMarkdownInput.setSelectionRange(
0,
this.imageMarkdownInput.value.length,
);
document.execCommand('copy');
this.setState({ showImageCopiedMessage: true });
}
clearUploadError() {
this.setState({
uploadError: false,
uploadErrorMessage: null,
});
}
render() {
const {
insertionImageUrls,
uploadError,
uploadErrorMessage,
showImageCopiedMessage,
} = this.state;
return (
<div className="flex items-center">
<Button
className="mr-2 fw-normal"
variant="ghost"
contentType="icon-left"
icon={ImageIcon}
tabIndex="-1"
>
Upload image
<input
type="file"
id="image-upload-field"
onChange={this.handleInsertionImageUpload}
className="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
multiple
accept="image/*"
data-max-file-size-mb="25"
tabIndex="-1"
/>
</Button>
{insertionImageUrls.length > 0 && (
<ClipboardButton
onCopy={this.copyText}
imageUrls={insertionImageUrls}
showCopyMessage={showImageCopiedMessage}
/>
)}
{uploadError && (
<span className="color-accent-danger">{uploadErrorMessage}</span>
)}
</div>
);
}
}
ImageUploader.displayName = 'ImageUploader';

View file

@ -0,0 +1,46 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { ArticleCoverImage } from './ArticleCoverImage';
import { TagsField } from './TagsField';
import { Title } from './Title';
export const Meta = ({
titleDefaultValue,
titleOnChange,
tagsDefaultValue,
tagsOnInput,
mainImage,
onMainImageUrlChange,
switchHelpContext,
}) => {
return (
<div>
<ArticleCoverImage
mainImage={mainImage}
onMainImageUrlChange={onMainImageUrlChange}
/>
<Title
defaultValue={titleDefaultValue}
onChange={titleOnChange}
switchHelpContext={switchHelpContext}
/>
<TagsField
defaultValue={tagsDefaultValue}
onInput={tagsOnInput}
switchHelpContext={switchHelpContext}
/>
</div>
);
};
Meta.propTypes = {
titleDefaultValue: PropTypes.string.isRequired,
titleOnChange: PropTypes.func.isRequired,
tagsDefaultValue: PropTypes.string.isRequired,
tagsOnInput: PropTypes.func.isRequired,
mainImage: PropTypes.string.isRequired,
onMainImageUrlChange: PropTypes.func.isRequired,
switchHelpContext: PropTypes.func.isRequired,
};
Meta.displayName = 'Meta';

View file

@ -0,0 +1,123 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Dropdown, Button } from '@crayons';
export const Options = ({
passedData: {
published = false,
allSeries = [],
canonicalUrl = '',
series = '',
},
onSaveDraft,
onConfigChange,
toggleMoreConfig,
moreConfigShowing,
}) => {
let publishedField = '';
let existingSeries = '';
if (allSeries.length > 0) {
const seriesNames = allSeries.map((name) => {
return (
<button
type="button"
name="series"
onClick={onConfigChange}
value={name}
>
{name}
</button>
);
});
existingSeries = (
<p className="crayons-field__description">
Existing series:
{` `}
{seriesNames}
</p>
);
}
if (published) {
publishedField = (
<div className="spec__options-danger-zone crayons-field mb-6">
<div className="crayons-field__label color-accent-danger">
Danger Zone
</div>
<Button variant="danger" onClick={onSaveDraft}>
Unpublish post
</Button>
</div>
);
}
return (
<Dropdown
className={
moreConfigShowing &&
'inline-block bottom-100 left-2 s:left-0 right-2 s:left-auto'
}
>
<h3 className="mb-6">Post options</h3>
<div className="crayons-field mb-6">
<label htmlFor="canonicalUrl" className="crayons-field__label">
Canonical URL
</label>
<p className="crayons-field__description">
Change meta tag
{` `}
<code>canonical_url</code>
{` `}
if this post was first published elsewhere (like your own blog).
</p>
<input
type="text"
value={canonicalUrl}
className="crayons-textfield"
placeholder="https://yoursite.com/post-title"
name="canonicalUrl"
onKeyUp={onConfigChange}
id="canonicalUrl"
/>
</div>
<div className="crayons-field mb-6">
<label htmlFor="series" className="crayons-field__label">
Series
</label>
<p className="crayons-field__description">
Will this post be part of a series? Give the series a unique name.
(Series visible once it has multiple posts)
</p>
<input
type="text"
value={series}
className="crayons-textfield"
name="series"
onKeyUp={onConfigChange}
id="series"
placeholder="..."
/>
{existingSeries}
</div>
{publishedField}
<Button className="w-100" data-content="exit" onClick={toggleMoreConfig}>
Done
</Button>
</Dropdown>
);
};
Options.propTypes = {
passedData: PropTypes.shape({
published: PropTypes.bool.isRequired,
allSeries: PropTypes.array.isRequired,
canonicalUrl: PropTypes.string.isRequired,
series: PropTypes.string.isRequired,
}).isRequired,
onSaveDraft: PropTypes.func.isRequired,
onConfigChange: PropTypes.func.isRequired,
toggleMoreConfig: PropTypes.func.isRequired,
moreConfigShowing: PropTypes.bool.isRequired,
};
Options.displayName = 'Options';

View file

@ -0,0 +1,34 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { OrganizationPicker } from '../../organization/OrganizationPicker';
export const PageTitle = ({organizations, organizationId, onToggle}) => {
return (
<div className="crayons-field__label flex items-center flex-1">
<span className="hidden s:inline-block whitespace-nowrap">
Write a new post
</span>
{organizations && organizations.length > 0 && (
<div className="mx-2">
<OrganizationPicker
name="article[organization_id]"
id="article_publish_under_org"
className="crayons-select mt-0"
organizations={organizations}
organizationId={organizationId}
onToggle={onToggle}
emptyLabel="Personal"
/>
</div>
)}
</div>
);
};
PageTitle.propTypes = {
organizations: PropTypes.string.isRequired,
organizationId: PropTypes.string.isRequired,
onToggle: PropTypes.string.isRequired,
};
PageTitle.displayName = 'Organization';

View file

@ -1,30 +1,19 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { ErrorList } from './ErrorList';
const CoverImage = ({ className, imageSrc, imageAlt }) => (
<div className={className}>
<img src={imageSrc} alt={imageAlt} />
</div>
);
CoverImage.propTypes = {
className: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
imageAlt: PropTypes.string.isRequired,
};
function titleArea(previewResponse, version, articleState) {
if (version === 'help') {
// possibly something different here in future.
return '';
}
function titleArea(previewResponse, articleState, errors) {
const tagArray = previewResponse.tags || articleState.tagList.split(', ');
let tags = '';
if (tagArray.length > 0 && tagArray[0].length > 0) {
tags = tagArray.map(tag => {
tags = tagArray.map((tag) => {
return (
<span>{tag.length > 0 ? <div className="tag">{tag}</div> : ''}</span>
tag.length > 0 && (
<span className="crayons-tag mr-2">
<span className="crayons-tag__prefix">#</span>
{tag}
</span>
)
);
});
}
@ -43,64 +32,26 @@ function titleArea(previewResponse, version, articleState) {
const previewTitle = previewResponse.title || articleState.title || '';
let coverImageHTML = '';
if (coverImage.length > 0) {
coverImageHTML = (
<CoverImage
className="articleform__mainimage articleform__mainimagepreview"
imageSrc={coverImage}
imageAlt="cover"
/>
);
}
return (
<div>
{coverImageHTML}
<div className="title" style={{ width: '90%', maxWidth: '1000px' }}>
<h1
className={
previewTitle.length > 44 ? 'articleform_titlepreviewsmall' : ''
}
>
{previewTitle}
</h1>
<h3>
<img
className="profile-pic"
src={window.currentUser.profile_image_90}
alt="profile"
<header className="crayons-article__header">
{coverImage.length > 0 && (
<div className="crayons-article__cover">
<div
className="crayons-article__cover__image"
style={{ backgroundImage: `url(${coverImage})` }}
/>
&nbsp;
<span>{window.currentUser.name}</span>
</h3>
<div className="tags">{tags}</div>
</div>
</div>
</div>
)}
{errors && <ErrorList errors={errors} />}
<h1 className="fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight mb-6 spec-article__title">
{previewTitle}
</h1>
<div className="crayons-article__tags">{tags}</div>
</header>
);
}
const BodyPreview = ({ previewResponse, version, articleState }) => (
<div
className="container"
style={{
marginTop: '10px',
minHeight: '508px',
overflow: 'hidden',
boxShadow: '0px 0px 0px #fff',
border: '0px',
}}
>
{titleArea(previewResponse, version, articleState)}
<div
className="body"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: previewResponse.processed_html }}
style={{ width: '90%' }}
/>
</div>
);
const previewResponsePropTypes = PropTypes.shape({
processed_html: PropTypes.string.isRequired,
title: PropTypes.string,
@ -108,9 +59,24 @@ const previewResponsePropTypes = PropTypes.shape({
cover_image: PropTypes.string,
});
BodyPreview.propTypes = {
export const Preview = ({ previewResponse, articleState, errors }) => {
return (
<div className="crayons-article-form__main__content crayons-card">
<article className="crayons-article">
{titleArea(previewResponse, articleState, errors)}
<div
className="crayons-article__body"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: previewResponse.processed_html }}
/>
</article>
</div>
);
};
Preview.propTypes = {
previewResponse: previewResponsePropTypes.isRequired,
version: PropTypes.string.isRequired,
errors: PropTypes.string.isRequired,
articleState: PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,
@ -143,4 +109,4 @@ BodyPreview.propTypes = {
}).isRequired,
};
export default BodyPreview;
Preview.displayName = 'Preview';

View file

@ -0,0 +1,35 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
export const Tabs = ({ onPreview, previewShowing }) => {
return (
<div className="crayons-article-form__tabs crayons-tabs ml-auto">
<button
className={`crayons-tabs__item ${
!previewShowing && 'crayons-tabs__item--current'
}`}
onClick={previewShowing && onPreview}
type="button"
>
Edit
</button>
<button
className={`crayons-tabs__item ${
previewShowing && 'crayons-tabs__item--current'
}`}
onClick={!previewShowing && onPreview}
type="button"
>
Preview
</button>
</div>
);
};
Tabs.propTypes = {
previewShowing: PropTypes.bool.isRequired,
onPreview: PropTypes.func.isRequired,
};
Tabs.displayName = 'Tabs';

View file

@ -0,0 +1,26 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import Tags from '../../shared/components/tags';
export const TagsField = ({ defaultValue, onInput, switchHelpContext }) => {
return (
<div className="crayons-article-form__tagsfield">
<Tags
defaultValue={defaultValue}
maxTags="4"
onInput={onInput}
onFocus={switchHelpContext}
classPrefix="crayons-article-form"
fieldClassName="crayons-textfield crayons-textfield--ghost ff-accent"
/>
</div>
);
};
TagsField.propTypes = {
onInput: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
switchHelpContext: PropTypes.func.isRequired,
};
TagsField.displayName = 'TagsField';

View file

@ -0,0 +1,34 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import Textarea from 'preact-textarea-autosize';
export const Title = ({ onChange, defaultValue, switchHelpContext }) => {
return (
<div className="crayons-article-form__title">
<Textarea
className="crayons-textfield crayons-textfield--ghost fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight"
type="text"
id="article-form-title"
placeholder="New post title here..."
autoComplete="off"
value={defaultValue}
onFocus={switchHelpContext}
onInput={onChange}
autofocus="true"
onKeyDown={(e) => {
if (e.keyCode === 13) {
e.preventDefault();
}
}}
/>
</div>
);
};
Title.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
switchHelpContext: PropTypes.func.isRequired,
};
Title.displayName = 'Title';

View file

@ -0,0 +1,21 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { ImageUploader } from './ImageUploader';
export const Toolbar = ({ version }) => {
return (
<div
className={`crayons-article-form__toolbar ${
version === 'v1' && 'border-t-0'
}`}
>
<ImageUploader />
</div>
);
};
Toolbar.propTypes = {
version: PropTypes.string.isRequired,
};
Toolbar.displayName = 'Toolbar';

View file

@ -0,0 +1,76 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { deep, shallow } from 'preact-render-spy';
import { ArticleCoverImage } from '../ArticleCoverImage';
describe('<ArticleCoverImage />', () => {
it('renders properly', () => {
const tree = render(
<ArticleCoverImage
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
onMainImageUrlChange={null}
/>,
);
expect(tree).toMatchSnapshot();
});
it('shows the correct view when there is an image uploaded', () => {
const container = shallow(
<ArticleCoverImage
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
onMainImageUrlChange={null}
/>,
);
expect(
container.find('.crayons-article-form__cover__image').exists(),
).toEqual(true);
expect(container.find('.crayons-article-form__cover').text()).toEqual(
'ChangeRemove',
);
expect(container.find('.articleform__uploaderror').exists()).toEqual(false);
});
it('shows the correct view when there is no image uploaded', () => {
const container = shallow(
<ArticleCoverImage mainImage={null} onMainImageUrlChange={null} />,
);
expect(
container.find('.crayons-article-form__cover__image').exists(),
).toEqual(false);
expect(container.find('.crayons-article-form__cover').text()).toEqual(
'Add a cover image',
);
});
it('displays an upload error when necessary', () => {
const context = shallow(
<ArticleCoverImage mainImage={null} onMainImageUrlChange={null} />,
);
expect(context.component()).toBeInstanceOf(ArticleCoverImage);
context.setState({
uploadError: true,
uploadErrorMessage: 'Some error message',
});
expect(context.find('.articleform__uploaderror').exists()).toEqual(true);
expect(context.find('.articleform__uploaderror').text()).toEqual(
'Some error message',
);
});
it('should trigger onMainImageUrlChange when the Remove button is pressed', () => {
const onMainImageUrlChange = jest.fn();
const context = deep(
<ArticleCoverImage
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
onMainImageUrlChange={onMainImageUrlChange}
/>,
);
context.find('.crayons-btn--ghost-danger').simulate('click', {
preventDefault: () => {},
});
expect(onMainImageUrlChange).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,18 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { Close } from '../Close';
describe('<Close />', () => {
it('renders properly', () => {
const tree = render(<Close />);
expect(tree).toMatchSnapshot();
});
it('shows the modal', () => {
const container = shallow(<Close />);
expect(container.find('.crayons-article-form__close').exists()).toEqual(
true,
);
});
});

View file

@ -0,0 +1,158 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { deep } from 'preact-render-spy';
import { Form } from '../Form';
let bodyMarkdown; let mainImage;
describe('<Form />', () => {
describe('v1', () => {
beforeEach(() => {
bodyMarkdown =
'---↵title: Test Title v1↵published: false↵description: some description↵tags: javascript, career↵cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/3/13d3b32a-d381-4549-b95e-ec665768ce8f.png↵---↵↵Lets do this v1 changes↵↵![Alt Text](/i/12qpyywb0jlj6hksp9fn.png)';
mainImage =
'https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/3/13d3b32a-d381-4549-b95e-ec665768ce8f.png';
});
it('renders properly', () => {
const tree = render(
<Form
titleDefaultValue="Test Title v1"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={null}
bodyHasFocus={false}
version="v1"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
expect(tree).toMatchSnapshot();
});
it('displays the correct elements', () => {
const container = deep(
<Form
titleDefaultValue="Test Title v1"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={null}
bodyHasFocus={false}
version="v1"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
expect(container.find('.crayons-article-form__cover').exists()).toEqual(
false,
);
expect(container.find('.crayons-article-form__title').exists()).toEqual(
false,
);
expect(
container.find('.crayons-article-form__tagsfield').exists(),
).toEqual(false);
expect(container.find('.crayons-article-form__body').exists()).toEqual(
true,
);
});
});
describe('v2', () => {
beforeEach(() => {
bodyMarkdown =
'---↵title: Test Title v2↵published: false↵description: some description↵tags: javascript, career↵cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png↵---↵↵Lets do this v2 changes↵↵![Alt Text](/i/12qpyywb0jlj6hksp9fn.png)';
mainImage =
'https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png';
});
it('renders properly', () => {
const tree = render(
<Form
titleDefaultValue="Test Title v2"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={null}
bodyHasFocus={false}
version="v2"
mainImage="https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png"
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
expect(tree).toMatchSnapshot();
});
it('displays the correct elements', () => {
const container = deep(
<Form
titleDefaultValue="Test Title v2"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={null}
bodyHasFocus={false}
version="v2"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
expect(container.find('.crayons-article-form__cover').exists()).toEqual(
true,
);
expect(container.find('.crayons-article-form__title').exists()).toEqual(
true,
);
expect(
container.find('.crayons-article-form__tagsfield').exists(),
).toEqual(true);
expect(container.find('.crayons-article-form__body').exists()).toEqual(
true,
);
});
});
it('shows errors if there are any', () => {
const errors = {
title: ["can't be blank"],
main_image: ['is not a valid URL'],
};
const container = deep(
<Form
titleDefaultValue="Test Title v2"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={null}
bodyHasFocus={false}
version="v2"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={errors}
switchHelpContext={null}
/>,
);
expect(container.find('.crayons-notice--danger').exists()).toEqual(true);
expect(container.find('.crayons-notice--danger').text()).toMatch('title');
expect(container.find('.crayons-notice--danger').text()).toMatch(
'main_image',
);
});
});

View file

@ -0,0 +1,139 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { Help } from '../Help';
describe('<Help />', () => {
it('renders properly', () => {
const tree = render(
<Help
previewShowing={false}
helpFor={null}
helpPosition={null}
version="v1"
/>,
);
expect(tree).toMatchSnapshot();
});
it('does not render help if we are in preview mode', () => {
const container = shallow(
<Help
previewShowing
helpFor={null}
helpPosition={null}
version="v1"
/>,
);
expect(
container.find('.crayons-article-form__main__aside').text().length,
).toEqual(0);
});
it('shows some help in edit mode', () => {
const container = shallow(
<Help
previewShowing={false}
helpFor={null}
helpPosition={null}
version="v1"
/>,
);
expect(
container.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
});
it('shows some help in edit mode', () => {
const container = shallow(
<Help
previewShowing={false}
helpFor={null}
helpPosition={null}
version="v1"
/>,
);
expect(
container.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
});
it('shows the correct help for v1', () => {
const container = shallow(
<Help
previewShowing={false}
helpFor={null}
helpPosition={null}
version="v1"
/>,
);
expect(
container.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
expect(container.find('.spec__basic-editor-help').exists()).toEqual(true);
expect(container.find('.spec__format-help').exists()).toEqual(true);
expect(container.find('.spec__title-help').exists()).toEqual(false);
expect(container.find('.spec__basic-tag-input-help').exists()).toEqual(
false,
);
});
it('shows the correct help section based on helpFor for v2', () => {
const container = shallow(
<Help
previewShowing={false}
helpFor="article-form-title"
helpPosition={null}
version="v2"
/>,
);
expect(
container.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
expect(container.find('.spec__title-help').exists()).toEqual(true);
expect(container.find('.spec__format-help').exists()).toEqual(false);
expect(container.find('.spec__basic-editor-help').exists()).toEqual(false);
expect(container.find('.spec__basic-tag-input-help').exists()).toEqual(
false,
);
const container2 = shallow(
<Help
previewShowing={false}
helpFor="article_body_markdown"
helpPosition={null}
version="v2"
/>,
);
expect(
container2.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
expect(container2.find('.spec__format-help').exists()).toEqual(true);
expect(container2.find('.spec__basic-editor-help').exists()).toEqual(false);
expect(container2.find('.spec__title-help').exists()).toEqual(false);
expect(container2.find('.spec__basic-tag-input-help').exists()).toEqual(
false,
);
const container3 = shallow(
<Help
previewShowing={false}
helpFor="tag-input"
helpPosition={null}
version="v2"
/>,
);
expect(
container3.find('.crayons-article-form__main__aside').text().length,
).toBeGreaterThan(0);
expect(container3.find('.spec__basic-tag-input-help').exists()).toEqual(
true,
);
expect(container3.find('.spec__format-help').exists()).toEqual(false);
expect(container3.find('.spec__basic-editor-help').exists()).toEqual(false);
expect(container3.find('.spec__title-help').exists()).toEqual(false);
});
// TODO: test the modals
});

View file

@ -0,0 +1,65 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow, deep } from 'preact-render-spy';
import { ImageUploader } from '../ImageUploader';
describe('<ImageUploader />', () => {
it('renders correctly without an image', () => {
const tree = render(<ImageUploader />);
expect(tree).toMatchSnapshot();
});
it('displays text to copy', () => {
const context = deep(<ImageUploader />);
expect(context.component()).toBeInstanceOf(ImageUploader);
context.setState({
insertionImageUrls: ['/i/jxuopxlscfy6wkfbbkvb.png'],
uploadError: false,
uploadErrorMessage: null,
});
expect(context.find('#image-markdown-copy-link-input').exists()).toEqual(
true,
);
});
it('displays Copied! when clicking on the icon', () => {
const context = deep(<ImageUploader />);
expect(context.component()).toBeInstanceOf(ImageUploader);
context.setState({
insertionImageUrls: ['/i/jxuopxlscfy6wkfbbkvb.png'],
uploadError: false,
uploadErrorMessage: null,
});
document.getElementById = jest.fn(() => {
return {
value: 'some copied text',
setSelectionRange: () => {},
};
});
document.execCommand = jest.fn();
context.find('clipboard-copy').simulate('click');
expect(context.find('ClipboardButton').attrs().showCopyMessage).toEqual(
true,
);
expect(context.find('#image-markdown-copy-link-announcer').text()).toEqual(
'Copied!',
);
});
it('displays an error when one occurs', () => {
const error = 'Some error message';
const context = shallow(<ImageUploader />);
expect(context.component()).toBeInstanceOf(ImageUploader);
context.setState({
insertionImageUrls: [],
uploadError: true,
uploadErrorMessage: error,
});
expect(context.find('.color-accent-danger').text()).toEqual(error);
});
});

View file

@ -0,0 +1,68 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { Options } from '../Options';
let passedData;
const getOptions = (passedData) => (
<Options
passedData={passedData}
onConfigChange={null}
onSaveDraft={null}
moreConfigShowing={null}
toggleMoreConfig={null}
/>
);
describe('<Options />', () => {
beforeEach(() => {
passedData = {
"id": null,
"title": "Test v2 Title",
"tagList": "javascript, career, ",
"description": "",
"canonicalUrl": "",
"series": "",
"allSeries": [
"Learn Something new a day"
],
"bodyMarkdown": "![Alt Text](/i/wsq3lro2l66f87kqiqrf.jpeg)\nLet's write something here...",
"published": false,
"previewShowing": false,
"previewResponse": "",
"submitting": false,
"editing": false,
"mainImage": "/i/9pouqdqxcl4f6rwk1yfd.jpg",
"organizations": [
{
"id": 4,
"bg_color_hex": "",
"name": "DEV",
"text_color_hex": "",
"profile_image_90": "/uploads/organization/profile_image/4/1689e7ae-6306-43cd-acba-8bde7ed80a17.JPG"
}
],
"organizationId": null,
"errors": null,
"edited": true,
"updatedAt": null,
"version": "v2",
"helpFor": "article_body_markdown",
"helpPosition": 421
}
});
it('renders properly', () => {
const tree = render(getOptions(passedData));
expect(tree).toMatchSnapshot();
});
it('shows the danger zone once an article is published', ()=> {
passedData.published = true;
const container = shallow(getOptions(passedData));
expect(container.find('.spec__options-danger-zone').exists()).toEqual(true,)
});
});

View file

@ -0,0 +1,48 @@
import { h } from 'preact';
import { shallow } from 'preact-render-spy';
import { PageTitle } from '../PageTitle';
let organizations; let organizationId; let onToggle;
describe('<PageTitle/>', () => {
beforeEach(() => {
organizations = [
{
id: 4,
bg_color_hex: '',
name: 'DEV',
text_color_hex: '',
profile_image_90:
'/uploads/organization/profile_image/4/1689e7ae-6306-43cd-acba-8bde7ed80a17.JPG',
},
];
organizationId = null;
onToggle = null;
});
it('shows the picker if there is more than one organisation', () => {
const container = shallow(
<PageTitle
organizations={organizations}
organizationId={organizationId}
onToggle={onToggle}
/>,
);
expect(container.find('#article_publish_under_org').exists()).toEqual(true);
});
it('does not show the picker if there is no organisations', () => {
organizations = [];
const container = shallow(
<PageTitle
organizations={organizations}
organizationId={organizationId}
onToggle={onToggle}
/>,
);
expect(container.find('#article_publish_under_org').exists()).toEqual(
false,
);
});
});

View file

@ -0,0 +1,137 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { JSDOM } from 'jsdom';
import { shallow } from 'preact-render-spy';
import { Preview } from '../Preview';
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
global.window = doc.defaultView;
global.window.currentUser = {
id: 1,
name: 'Guy Fieri',
username: 'guyfieri',
profile_image_90:
'/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png',
};
let previewResponse;
let articleState;
let errors;
describe('<Preview />', () => {
beforeEach(() => {
previewResponse = {
processed_html:
'<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵',
title: 'My Awesome Post',
tags: null,
cover_image: 'http://lorempixel.com/400/200/',
};
articleState = {
id: null,
title: 'My Awesome Post',
tagList: 'javascript, career, ',
description: 'Some description',
canonicalUrl: '',
series: '',
allSeries: ['Learn Something new a day'],
bodyMarkdown:
'---↵title: My Awesome Post↵published: false↵description: ↵tags: ↵---↵↵My Awesome Post Not very long, but still very awesome! ↵',
submitting: false,
editing: false,
mainImage: '/i/9ca8kb1cu34mobypm5yx.png',
organizations: [
{
id: 4,
bg_color_hex: '',
name: 'DEV',
text_color_hex: '',
profile_image_90:
'/uploads/organization/profile_image/4/1689e7ae-6306-43cd-acba-8bde7ed80a17.JPG',
},
],
organizationId: null,
errors: null,
edited: true,
updatedAt: null,
version: 'v2',
helpFor: null,
helpPosition: null,
};
errors = null;
});
it('renders properly', () => {
const tree = render(
<Preview
previewResponse={previewResponse}
articleState={articleState}
errors={errors}
/>,
);
expect(tree).toMatchSnapshot();
});
it('shows the correct title', () => {
const container = shallow(
<Preview
previewResponse={previewResponse}
articleState={articleState}
errors={errors}
/>,
);
expect(container.find('.spec-article__title').text()).toEqual(
previewResponse.title,
);
});
it('shows the correct tags', () => {
const container = shallow(
<Preview
previewResponse={previewResponse}
articleState={articleState}
errors={errors}
/>,
);
expect(container.find('.crayons-article__tags').text()).toEqual(
'#javascript#career',
);
});
it('shows a cover image in the preview if one exists', () => {
const container = shallow(
<Preview
previewResponse={previewResponse}
articleState={articleState}
errors={errors}
/>,
);
expect(container.find('.crayons-article__cover__image').exists()).toEqual(
true,
);
});
it('does not show a cover image in the preview if one does not exists', () => {
previewResponse.cover_image = null;
articleState.mainImage = null;
const container = shallow(
<Preview
previewResponse={previewResponse}
articleState={articleState}
errors={errors}
/>,
);
expect(container.find('.crayons-article__cover__image').exists()).toEqual(
false,
);
});
// TODO: need to write a test for the cover image for v1
});

View file

@ -0,0 +1,25 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { Tabs } from '../Tabs';
describe('<Tabs />', () => {
it('renders properly', () => {
const tree = render(<Tabs onPreview={null} previewShowing={false} />);
expect(tree).toMatchSnapshot();
});
it('highlights the current tab', () => {
const container1 = shallow(<Tabs onPreview={null} previewShowing />);
expect(container1.find('.crayons-tabs__item--current').text()).toEqual(
'Preview',
);
const container2 = shallow(
<Tabs onPreview={null} previewShowing={false} />,
);
expect(container2.find('.crayons-tabs__item--current').text()).toEqual(
'Edit',
);
});
});

View file

@ -0,0 +1,16 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { Title } from '../Title';
describe('<Title />', () => {
it('renders properly', () => {
const tree = render(
<Title
defaultValue="Test title"
onChange={null}
switchHelpContext={null}
/>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ArticleCoverImage /> renders properly 1`] = `
<div
class="crayons-article-form__cover"
role="presentation"
>
<img
alt="Post cover"
class="crayons-article-form__cover__image"
height="105"
src="/i/r5tvutqpl7th0qhzcw7f.png"
width="250"
/>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--outlined mr-2 whitespace-nowrap"
disabled={false}
type="button"
>
<label
htmlFor="cover-image-input"
>
Change
</label>
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="cover-image-input"
onChange={[Function]}
type="file"
/>
</button>
<button
class="crayons-btn crayons-btn--ghost-danger"
disabled={false}
onClick={[Function]}
type="button"
>
Remove
</button>
</div>
</div>
`;

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Close /> renders properly 1`] = `
<div
class="crayons-article-form__close"
>
<a
class="crayons-btn crayons-btn--ghost crayons-btn--icon"
href="/"
title="Close the editor"
>
<svg
aria-labelledby="as1mn15llu5e032u2pgzlc6yhvss2myk"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="as1mn15llu5e032u2pgzlc6yhvss2myk"
>
Close the editor
</title>
<path
d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z"
/>
</svg>
</a>
</div>
`;

View file

@ -0,0 +1,233 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Form /> v1 renders properly 1`] = `
<div
class="crayons-article-form__main__content crayons-card"
>
<div
class="crayons-article-form__body"
>
<div
class="crayons-article-form__toolbar border-t-0"
>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--ghost crayons-btn--icon-left mr-2 fw-normal"
disabled={false}
tabIndex="-1"
type="button"
>
<svg
aria-labelledby="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
>
Upload image
</title>
<path
d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"
/>
</svg>
Upload image
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="image-upload-field"
multiple={true}
onChange={[Function]}
tabIndex="-1"
type="file"
/>
</button>
</div>
</div>
<textarea
class="crayons-textfield crayons-textfield--ghost crayons-article-form__body__field"
id="article_body_markdown"
name="body_markdown"
onChange={[Function]}
onFocus={[Function]}
onInput={null}
placeholder="Write your post content here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
value="---↵title: Test Title v1↵published: false↵description: some description↵tags: javascript, career↵cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/3/13d3b32a-d381-4549-b95e-ec665768ce8f.png↵---↵↵Lets do this v1 changes↵↵![Alt Text](/i/12qpyywb0jlj6hksp9fn.png)"
/>
</div>
</div>
`;
exports[`<Form /> v2 renders properly 1`] = `
<div
class="crayons-article-form__main__content crayons-card"
>
<div>
<div
class="crayons-article-form__cover"
role="presentation"
>
<img
alt="Post cover"
class="crayons-article-form__cover__image"
height="105"
src="https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png"
width="250"
/>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--outlined mr-2 whitespace-nowrap"
disabled={false}
type="button"
>
<label
htmlFor="cover-image-input"
>
Change
</label>
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="cover-image-input"
onChange={[Function]}
type="file"
/>
</button>
<button
class="crayons-btn crayons-btn--ghost-danger"
disabled={false}
onClick={[Function]}
type="button"
>
Remove
</button>
</div>
</div>
<div
class="crayons-article-form__title"
>
<textarea
autoComplete="off"
autofocus="true"
class="crayons-textfield crayons-textfield--ghost fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight"
id="article-form-title"
onChange={[Function]}
onFocus={null}
onInput={null}
onKeyDown={[Function]}
placeholder="New post title here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
type="text"
value="Test Title v2"
/>
</div>
<div
class="crayons-article-form__tagsfield"
>
<div
class="crayons-article-form__tagswrapper"
>
<input
autoComplete="off"
class="crayons-textfield crayons-textfield--ghost ff-accent crayons-article-form__tags"
id="tag-input"
name="listing[tag_list]"
onBlur={[Function]}
onFocus={null}
onInput={[Function]}
onKeyDown={[Function]}
placeholder="Add up to 4 tags..."
ref={[Function]}
type="text"
value="javascript, career"
/>
</div>
</div>
</div>
<div
class="crayons-article-form__body"
>
<div
class="crayons-article-form__toolbar false"
>
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--ghost crayons-btn--icon-left mr-2 fw-normal"
disabled={false}
tabIndex="-1"
type="button"
>
<svg
aria-labelledby="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
>
Upload image
</title>
<path
d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"
/>
</svg>
Upload image
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="image-upload-field"
multiple={true}
onChange={[Function]}
tabIndex="-1"
type="file"
/>
</button>
</div>
</div>
<textarea
class="crayons-textfield crayons-textfield--ghost crayons-article-form__body__field"
id="article_body_markdown"
name="body_markdown"
onChange={[Function]}
onFocus={[Function]}
onInput={null}
placeholder="Write your post content here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
value="---↵title: Test Title v2↵published: false↵description: some description↵tags: javascript, career↵cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/badge/badge_image/12/8_week_streak-Shadow.png↵---↵↵Lets do this v2 changes↵↵![Alt Text](/i/12qpyywb0jlj6hksp9fn.png)"
/>
</div>
</div>
`;

View file

@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Help /> renders properly 1`] = `
<div
class="crayons-article-form__main__aside"
>
<div
class="sticky"
style={
Object {
"top": "56px",
}
}
>
<div
class="spec__basic-editor-help crayons-card crayons-card--secondary p-4 mb-6"
>
You are currently using the basic markdown editor that uses
<a
href="#frontmatter"
onClick={[Function]}
>
Jekyll front matter
</a>
. You can also use the
<em>
rich+markdown
</em>
editor you can find in
<a
href="/settings/ux"
>
UX settings
<svg
aria-labelledby="c038a36b2512ed25db907e179ab45cfc"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="c038a36b2512ed25db907e179ab45cfc"
>
Open UX settings
</title>
<path
d="M10.667 8v1.333H7.333v7.334h7.334v-3.334H16v4a.666.666 0 01-.667.667H6.667A.666.666 0 016 17.333V8.667A.667.667 0 016.667 8h4zM18 6v5.333h-1.333V8.275l-5.196 5.196-.942-.942 5.194-5.196h-3.056V6H18z"
/>
</svg>
</a>
.
</div>
<div
class="spec__format-help crayons-article-form__help crayons-article-form__help--body"
>
<h4
class="mb-2 fs-l"
>
Editor Basics
</h4>
<ul
class="list-disc pl-6 color-base-70"
>
<li>
Use
<a
href="#markdown"
onClick={[Function]}
>
Markdown
</a>
to write and format posts.
</li>
<li>
You can use
<a
href="#liquid"
onClick={[Function]}
>
Liquid tags
</a>
to add rich content such as Tweets, YouTube videos, etc.
</li>
</ul>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ImageUploader /> renders correctly without an image 1`] = `
<div
class="flex items-center"
>
<button
class="crayons-btn crayons-btn--ghost crayons-btn--icon-left mr-2 fw-normal"
disabled={false}
tabIndex="-1"
type="button"
>
<svg
aria-labelledby="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
class="crayons-icon"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="a17qec5pfhrwzk9w4kg0tp62v27qqu9t"
>
Upload image
</title>
<path
d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"
/>
</svg>
Upload image
<input
accept="image/*"
class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer"
data-max-file-size-mb="25"
id="image-upload-field"
multiple={true}
onChange={[Function]}
tabIndex="-1"
type="file"
/>
</button>
</div>
`;

View file

@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Options /> renders properly 1`] = `
<div
class="crayons-dropdown"
>
<h3
class="mb-6"
>
Post options
</h3>
<div
class="crayons-field mb-6"
>
<label
class="crayons-field__label"
htmlFor="canonicalUrl"
>
Canonical URL
</label>
<p
class="crayons-field__description"
>
Change meta tag
<code>
canonical_url
</code>
if this post was first published elsewhere (like your own blog).
</p>
<input
class="crayons-textfield"
id="canonicalUrl"
name="canonicalUrl"
onKeyUp={null}
placeholder="https://yoursite.com/post-title"
type="text"
value=""
/>
</div>
<div
class="crayons-field mb-6"
>
<label
class="crayons-field__label"
htmlFor="series"
>
Series
</label>
<p
class="crayons-field__description"
>
Will this post be part of a series? Give the series a unique name. (Series visible once it has multiple posts)
</p>
<input
class="crayons-textfield"
id="series"
name="series"
onKeyUp={null}
placeholder="..."
type="text"
value=""
/>
<p
class="crayons-field__description"
>
Existing series:
<button
name="series"
onClick={null}
type="button"
value="Learn Something new a day"
>
Learn Something new a day
</button>
</p>
</div>
<button
class="crayons-btn w-100"
disabled={false}
onClick={null}
type="button"
>
Done
</button>
</div>
`;

View file

@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Preview /> renders properly 1`] = `
<div
class="crayons-article-form__main__content crayons-card"
>
<article
class="crayons-article"
>
<header
class="crayons-article__header"
>
<div
class="crayons-article__cover"
>
<div
class="crayons-article__cover__image"
style={
Object {
"backgroundImage": "url(/i/9ca8kb1cu34mobypm5yx.png)",
}
}
/>
</div>
<h1
class="fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight mb-6 spec-article__title"
>
My Awesome Post
</h1>
<div
class="crayons-article__tags"
>
<span
class="crayons-tag mr-2"
>
<span
class="crayons-tag__prefix"
>
#
</span>
javascript
</span>
<span
class="crayons-tag mr-2"
>
<span
class="crayons-tag__prefix"
>
#
</span>
career
</span>
</div>
</header>
<div
class="crayons-article__body"
dangerouslySetInnerHTML={
Object {
"__html": "<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵",
}
}
/>
</article>
</div>
`;

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Tabs /> renders properly 1`] = `
<div
class="crayons-article-form__tabs crayons-tabs ml-auto"
>
<button
class="crayons-tabs__item crayons-tabs__item--current"
onClick={false}
type="button"
>
Edit
</button>
<button
class="crayons-tabs__item false"
onClick={null}
type="button"
>
Preview
</button>
</div>
`;

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Title /> renders properly 1`] = `
<div
class="crayons-article-form__title"
>
<textarea
autoComplete="off"
autofocus="true"
class="crayons-textfield crayons-textfield--ghost fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight"
id="article-form-title"
onChange={[Function]}
onFocus={null}
onInput={null}
onKeyDown={[Function]}
placeholder="New post title here..."
ref={[Function]}
style={
Object {
"height": 0,
}
}
type="text"
value="Test title"
/>
</div>
`;

View file

@ -0,0 +1,6 @@
export * from './EditorActions';
export * from './Form';
export * from './Header';
export * from './Help';
export * from './Preview';
export * from './keyboardShortcutsHandler';

View file

@ -1,7 +1,7 @@
import { Component } from 'preact';
import PropTypes from 'prop-types';
class KeyboardShortcutsHandler extends Component {
export class KeyboardShortcutsHandler extends Component {
componentDidMount() {
document.addEventListener('keydown', this.handleKeyDown());
}
@ -35,4 +35,4 @@ KeyboardShortcutsHandler.propTypes = {
togglePreview: PropTypes.func.isRequired,
};
export default KeyboardShortcutsHandler;
KeyboardShortcutsHandler.displayName = 'KeyboardShortcutsHandler';

View file

@ -1,249 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<bodyPreview version="v1" /> renders properly with an image 1`] = `
<div
class="container"
style={
Object {
"border": "0px",
"boxShadow": "0px 0px 0px #fff",
"marginTop": "10px",
"minHeight": "508px",
"overflow": "hidden",
}
}
>
<div>
<div
class="articleform__mainimage articleform__mainimagepreview"
>
<img
alt="cover"
src="http://lorempixel.com/400/200/"
/>
</div>
<div
class="title"
style={
Object {
"maxWidth": "1000px",
"width": "90%",
}
}
>
<h1>
My Awesome Post
</h1>
<h3>
<img
alt="profile"
class="profile-pic"
src="/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png"
/>
 
<span>
Guy Fieri
</span>
</h3>
<div
class="tags"
/>
</div>
</div>
<div
class="body"
dangerouslySetInnerHTML={
Object {
"__html": "<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵",
}
}
style={
Object {
"width": "90%",
}
}
/>
</div>
`;
exports[`<bodyPreview version="v1" /> renders properly without an image 1`] = `
<div
class="container"
style={
Object {
"border": "0px",
"boxShadow": "0px 0px 0px #fff",
"marginTop": "10px",
"minHeight": "508px",
"overflow": "hidden",
}
}
>
<div>
<div
class="title"
style={
Object {
"maxWidth": "1000px",
"width": "90%",
}
}
>
<h1>
My Awesome Post
</h1>
<h3>
<img
alt="profile"
class="profile-pic"
src="/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png"
/>
 
<span>
Guy Fieri
</span>
</h3>
<div
class="tags"
/>
</div>
</div>
<div
class="body"
dangerouslySetInnerHTML={
Object {
"__html": "<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵",
}
}
style={
Object {
"width": "90%",
}
}
/>
</div>
`;
exports[`<bodyPreview version="v2" /> renders properly with an image 1`] = `
<div
class="container"
style={
Object {
"border": "0px",
"boxShadow": "0px 0px 0px #fff",
"marginTop": "10px",
"minHeight": "508px",
"overflow": "hidden",
}
}
>
<div>
<div
class="articleform__mainimage articleform__mainimagepreview"
>
<img
alt="cover"
src="http://lorempixel.com/400/200/"
/>
</div>
<div
class="title"
style={
Object {
"maxWidth": "1000px",
"width": "90%",
}
}
>
<h1>
My Awesome Post
</h1>
<h3>
<img
alt="profile"
class="profile-pic"
src="/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png"
/>
 
<span>
Guy Fieri
</span>
</h3>
<div
class="tags"
/>
</div>
</div>
<div
class="body"
dangerouslySetInnerHTML={
Object {
"__html": "<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵",
}
}
style={
Object {
"width": "90%",
}
}
/>
</div>
`;
exports[`<bodyPreview version="v2" /> renders properly without an image 1`] = `
<div
class="container"
style={
Object {
"border": "0px",
"boxShadow": "0px 0px 0px #fff",
"marginTop": "10px",
"minHeight": "508px",
"overflow": "hidden",
}
}
>
<div>
<div
class="title"
style={
Object {
"maxWidth": "1000px",
"width": "90%",
}
}
>
<h1>
My Awesome Post
</h1>
<h3>
<img
alt="profile"
class="profile-pic"
src="/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png"
/>
 
<span>
Guy Fieri
</span>
</h3>
<div
class="tags"
/>
</div>
</div>
<div
class="body"
dangerouslySetInnerHTML={
Object {
"__html": "<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵",
}
}
style={
Object {
"width": "90%",
}
}
/>
</div>
`;

View file

@ -1,12 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Description /> should render the description 1`] = `
<textarea
class="articleform__description"
name="description"
onChange={[Function]}
placeholder="description"
type="text"
value="Some description"
/>
`;

View file

@ -1,25 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Errors /> renders properly 1`] = `
<div
class="articleform__errors"
>
<h2>
<span
aria-label="face screaming in fear"
role="img"
>
😱
</span>
  Heads up:
</h2>
<ul>
<li>
0:  Error 1
</li>
<li>
1:  Error 2
</li>
</ul>
</div>
`;

View file

@ -1,15 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MainImage /> renders properly 1`] = `
<div
class="articleform__mainimage"
onClick={[MockFunction]}
onKeyUp={[Function]}
role="presentation"
>
<img
alt=""
src="http://lorempixel.com/400/200/"
/>
</div>
`;

View file

@ -1,33 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<notice /> renders properly when published with a first version 1`] = `
<div
class="articleform__notice articleform__notice--publishing"
>
Saving ...
</div>
`;
exports[`<notice /> renders properly when published with a second version 1`] = `
<div
class="articleform__notice articleform__notice--publishing"
>
Publishing...
</div>
`;
exports[`<notice /> renders properly when unpublished with a version 1`] = `
<div
class="articleform__notice articleform__notice--draft"
>
Saving Draft...
</div>
`;
exports[`<notice /> renders properly when unpublished without a version 1`] = `
<div
class="articleform__notice articleform__notice--draft"
>
Saving ...
</div>
`;

View file

@ -1,102 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Tags /> only allows 4 tags 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 0,
"prevLen": 0,
"searchResults": Array [],
"selectedIndex": -1,
"showingRulesForTag": null,
}
`;
exports[`<Tags /> renders properly 1`] = `
<div
class="articleform__tagswrapper"
>
<input
autoComplete="off"
class="articleform__tags"
id="tag-input"
name="listing[tag_list]"
onBlur={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
placeholder="4 tags max, comma separated, no spaces or special characters"
ref={[Function]}
type="text"
value=""
/>
</div>
`;
exports[`<Tags /> replaces tag when editing 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 0,
"prevLen": 0,
"searchResults": Array [],
"selectedIndex": -1,
"showingRulesForTag": null,
}
`;
exports[`<Tags /> selects tag when you click on it 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 0,
"prevLen": 0,
"searchResults": Array [],
"selectedIndex": -1,
"showingRulesForTag": null,
}
`;
exports[`<Tags /> shows tags as you search 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 2,
"prevLen": 0,
"searchResults": Array [
Object {
"hotness_score": 0,
"name": "git",
"short_summary": null,
"supported": true,
},
],
"selectedIndex": 0,
"showingRulesForTag": null,
}
`;
exports[`<Tags /> shows tags when editing 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 2,
"prevLen": 0,
"searchResults": Array [
Object {
"hotness_score": 0,
"name": "git",
"short_summary": null,
"supported": true,
},
],
"selectedIndex": 0,
"showingRulesForTag": null,
}
`;
exports[`<Tags /> skips the click handler if className is articleform__tagsoptionrulesbutton 1`] = `
Object {
"additionalTags": Array [],
"cursorIdx": 0,
"prevLen": 0,
"searchResults": Array [],
"selectedIndex": -1,
"showingRulesForTag": null,
}
`;

View file

@ -1,165 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { JSDOM } from 'jsdom';
import { shallow } from 'preact-render-spy';
import BodyPreview from '../bodyPreview';
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
global.window = doc.defaultView;
global.window.currentUser = {
id: 1,
name: 'Guy Fieri',
username: 'guyfieri',
profile_image_90:
'/uploads/user/profile_image/41/0841dbe2-208c-4daa-b498-b2f01f3d37b2.png',
};
let previewResponse;
let articleState;
describe('<bodyPreview version="v1" />', () => {
const version = 'v1';
beforeEach(() => {
previewResponse = {
processed_html:
'<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵',
title: 'My Awesome Post',
tags: null,
cover_image: 'http://lorempixel.com/400/200/',
};
articleState = {
id: 1,
title: 'My Awesome Post',
tagList: '',
bodyMarkdown:
'---↵title: My Awesome Post↵published: false↵description: ↵tags: ↵---↵↵My Awesome Post Not very long, but still very awesome! ↵',
published: false,
previewShowing: true,
previewResponse,
};
});
it('renders properly with an image', () => {
const tree = render(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(tree).toMatchSnapshot();
});
it('shows an image in preview if one exists', () => {
const container = shallow(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(container.find('.articleform__mainimagepreview').exists()).toEqual(
true,
);
});
it('renders properly without an image', () => {
previewResponse.cover_image = null;
const tree = render(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(tree).toMatchSnapshot();
});
it('does not show an image in preview if one does not exist', () => {
previewResponse.cover_image = null;
const container = shallow(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(container.find('.articleform__mainimagepreview').exists()).toEqual(
false,
);
});
});
describe('<bodyPreview version="v2" />', () => {
const version = 'v2';
beforeEach(() => {
previewResponse = {
processed_html:
'<p>My Awesome Post! Not very long, but still very awesome.</p>↵↵',
};
articleState = {
id: 1,
title: 'My Awesome Post',
tagList: '',
bodyMarkdown:
'---↵title: My Awesome Post↵published: false↵description: ↵tags: ↵---↵↵My Awesome Post Not very long, but still very awesome! ↵',
mainImage: 'http://lorempixel.com/400/200/',
published: false,
previewShowing: true,
previewResponse,
};
});
it('renders properly with an image', () => {
const tree = render(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(tree).toMatchSnapshot();
});
it('shows an image in preview if one exists', () => {
const container = shallow(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(container.find('.articleform__mainimagepreview').exists()).toEqual(
true,
);
});
it('renders properly without an image', () => {
articleState.mainImage = null;
const tree = render(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(tree).toMatchSnapshot();
});
it('does not show an image in preview if one does not exist', () => {
articleState.mainImage = null;
const container = shallow(
<BodyPreview
previewResponse={previewResponse}
version={version}
articleState={articleState}
/>,
);
expect(container.find('.articleform__mainimagepreview').exists()).toEqual(
false,
);
});
});

View file

@ -1,17 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import Description from '../description';
describe('<Description />', () => {
it('should render the description', () => {
const context = render(
<Description
defaultValue="Some description"
onChange={() => {
return 'onChange';
}}
/>,
);
expect(context).toMatchSnapshot();
});
});

View file

@ -1,15 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import Errors from '../errors';
const errorsList = [
'Error 1',
'Error 2'
];
describe('<Errors />', () => {
it('renders properly', () => {
const tree = render(<Errors errorsList={errorsList} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,19 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import MainImage from '../mainImage';
describe('<MainImage />', () => {
const editHandler = jest.fn();
it('renders properly', () => {
const tree = render(<MainImage mainImage='http://lorempixel.com/400/200/' onEdit={editHandler} />);
expect(tree).toMatchSnapshot();
});
it('fires onEdit when clicked', () => {
const container = shallow(<MainImage mainImage='http://lorempixel.com/400/200/' onEdit={editHandler} />);
container.find('.articleform__mainimage').simulate('click');
expect(editHandler).toHaveBeenCalled();
});
});

View file

@ -1,25 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import Notice from '../notice';
describe('<notice />', () => {
it('renders properly when published with a first version', () => {
const tree = render(<Notice published version='v1' />);
expect(tree).toMatchSnapshot();
});
it('renders properly when published with a second version', () => {
const tree = render(<Notice published version='v2' />);
expect(tree).toMatchSnapshot();
});
it('renders properly when unpublished without a version', () => {
const tree = render(<Notice version='' />);
expect(tree).toMatchSnapshot();
});
it('renders properly when unpublished with a version', () => {
const tree = render(<Notice version='v2' />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,174 +0,0 @@
import { h, render as preactRender } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { JSDOM } from 'jsdom';
import fetch from 'jest-fetch-mock';
import Tags from '../../../shared/components/tags';
/* global globalThis */
const sampleResponse = JSON.stringify({
result: [
{
name: 'git',
hotness_score: 0,
supported: true,
short_summary: null,
},
],
});
describe('<Tags />', () => {
beforeAll(() => {
const csrfToken = 'this-is-a-csrf-token';
globalThis.fetch = fetch;
globalThis.getCsrfToken = async () => csrfToken;
});
afterAll(() => {
delete globalThis.fetch;
delete globalThis.getCsrfToken;
});
beforeEach(() => {
const doc = new JSDOM('<!doctype html><html><body></body></html>');
global.document = doc;
global.window = doc.defaultView;
fetch.mockResponse(sampleResponse);
});
it('renders properly', () => {
const tree = render(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
);
expect(tree).toMatchSnapshot();
});
it('shows tags as you search', () => {
const context = shallow(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
);
const component = context.component();
return component
.handleInput({ target: { value: 'gi', selectionStart: 2 } })
.then(() => {
expect(context.state()).toMatchSnapshot();
});
});
it('skips the click handler if className is articleform__tagsoptionrulesbutton', () => {
// eslint-disable-next-line no-underscore-dangle
const component = preactRender(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
document.body,
document.body.firstElementChild,
)._component;
component.handleTagClick({
target: { className: 'articleform__tagsoptionrulesbutton' },
});
expect(component.state).toMatchSnapshot();
expect(component.state.searchResults).toEqual([]);
});
it('selects tag when you click on it', () => {
// eslint-disable-next-line no-underscore-dangle
const component = preactRender(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
document.body,
document.body.firstElementChild,
)._component;
component.handleTagClick({
target: {},
currentTarget: { dataset: { content: 'git' } },
});
expect(component.state).toMatchSnapshot();
});
it('replaces tag when editing', () => {
// eslint-disable-next-line no-underscore-dangle
const component = preactRender(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
document.body,
document.body.firstElementChild,
)._component;
const input = document.getElementById('tag-input');
input.value = 'java,javascript,linux';
input.selectionStart = 2;
component.handleTagClick({
target: {},
currentTarget: { dataset: { content: 'git' } },
});
expect(component.state).toMatchSnapshot();
});
it('shows tags when editing', () => {
// eslint-disable-next-line no-underscore-dangle
const component = preactRender(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
document.body,
document.body.firstElementChild,
)._component;
return component
.handleInput({
target: { value: 'gi, javascript, linux', selectionStart: 2 },
})
.then(() => {
expect(component.state).toMatchSnapshot();
});
});
it('only allows 4 tags', () => {
const component = shallow(
<Tags
defaultValue=""
onInput={jest.fn()}
classPrefix="articleform"
maxTags={4}
/>,
);
component.simulate('input', {
target: { value: 'java, javascript, linux, productivity' },
});
component.simulate('keydown', { keyCode: 188, preventDefault: jest.fn() });
expect(component.state()).toMatchSnapshot();
});
});

View file

@ -1,20 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const BodyMarkdown = ({ onChange, defaultValue }) => (
<textarea
className="articleform__body"
id="article_body_markdown"
placeholder="Body Markdown"
value={defaultValue}
onInput={onChange}
name="body_markdown"
/>
);
BodyMarkdown.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
};
export default BodyMarkdown;

View file

@ -1,20 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const Description = ({ onChange, defaultValue }) => (
<textarea
className="articleform__description"
type="text"
placeholder="description"
name="description"
value={defaultValue}
onChange={onChange}
/>
);
Description.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
};
export default Description;

View file

@ -1,30 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const Errors = ({ errorsList }) => (
<div className="articleform__errors">
<h2>
<span role="img" aria-label="face screaming in fear">
😱
</span>
&nbsp; Heads up:
</h2>
<ul>
{Object.keys(errorsList).map(key => {
return (
<li>
{key}
: &nbsp;
{errorsList[key]}
</li>
);
})}
</ul>
</div>
);
Errors.propTypes = {
errorsList: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default Errors;

View file

@ -1,258 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import linkCopyIcon from '../../../assets/images/content-copy.svg';
import { generateMainImage } from '../actions';
import { validateFileInputs } from '../../packs/validateFileInputs';
export default class ImageManagement extends Component {
constructor(props) {
super(props);
this.state = {
insertionImageUrls: [],
uploadError: false,
uploadErrorMessage: null,
};
}
handleMainImageUpload = e => {
e.preventDefault();
this.clearUploadError();
const validFileInputs = validateFileInputs();
if (validFileInputs) {
const payload = { image: e.target.files, wrap_cloudinary: true };
const { onMainImageUrlChange } = this.props;
generateMainImage(payload, onMainImageUrlChange, this.onUploadError);
}
};
handleInsertionImageUpload = e => {
this.clearUploadError();
const validFileInputs = validateFileInputs();
if (validFileInputs) {
const payload = { image: e.target.files };
generateMainImage(
payload,
this.handleInsertImageUploadSuccess,
this.onUploadError,
);
}
};
handleInsertImageUploadSuccess = response => {
this.setState({
insertionImageUrls: response.links,
});
};
triggerMainImageRemoval = e => {
e.preventDefault();
const { onMainImageUrlChange } = this.props;
onMainImageUrlChange({
links: [],
});
};
clearUploadError = () => {
this.setState({
uploadError: false,
uploadErrorMessage: null,
});
};
onUploadError = error => {
this.setState({
insertionImageUrls: [],
uploadError: true,
uploadErrorMessage: error.message,
});
};
execCopyText = () => {
this.imageMarkdownInput.setSelectionRange(
0,
this.imageMarkdownInput.value.length,
);
document.execCommand('copy');
this.imageMarkdownAnnouncer.hidden = false;
}
copyText = () => {
this.imageMarkdownAnnouncer = document.getElementById(
'image-markdown-copy-link-announcer',
);
this.imageMarkdownInput = document.getElementById(
'image-markdown-copy-link-input',
);
const isNativeAndroid =
navigator.userAgent === 'DEV-Native-android' &&
typeof AndroidBridge !== "undefined" &&
AndroidBridge !== null;
const isClipboardSupported =
typeof navigator.clipboard !== "undefined" &&
navigator.clipboard !== null;
if (isNativeAndroid) {
AndroidBridge.copyToClipboard(this.imageMarkdownInput.value);
this.imageMarkdownAnnouncer.hidden = false;
} else if (isClipboardSupported) {
navigator.clipboard.writeText(this.imageMarkdownInput.value)
.then(() => {
this.imageMarkdownAnnouncer.hidden = false;
})
.catch((err) => {
this.execCopyText();
});
} else {
this.execCopyText();
}
};
linksToMarkdownForm = imageLinks => {
return imageLinks.map(imageLink => `![Alt Text](${imageLink})`).join('\n');
};
linksToDirectForm = imageLinks => {
return imageLinks.join('\n');
};
render() {
const { onExit, mainImage, version } = this.props;
const { insertionImageUrls, uploadError, uploadErrorMessage } = this.state;
let mainImageArea;
if (mainImage) {
mainImageArea = (
<div>
<img src={mainImage} alt="main" />
<button type="button" onClick={this.triggerMainImageRemoval}>
Remove Cover Image
</button>
</div>
);
} else {
mainImageArea = (
<div>
<input
type="file"
onChange={this.handleMainImageUpload}
accept="image/*"
data-max-file-size-mb="25"
/>
</div>
);
}
let insertionImageArea;
if (insertionImageUrls.length > 0) {
insertionImageArea = (
<div>
<h3>Markdown Images:</h3>
<clipboard-copy
onClick={this.copyText}
for="image-markdown-copy-link-input"
aria-live="polite"
aria-controls="image-markdown-copy-link-announcer"
>
<textarea
id="image-markdown-copy-link-input"
value={this.linksToMarkdownForm(insertionImageUrls)}
/>
<img
id="image-markdown-copy-icon"
src={linkCopyIcon}
alt="Copy to Clipboard"
/>
<span id="image-markdown-copy-link-announcer" role="alert" hidden>
Copied to Clipboard
</span>
</clipboard-copy>
<h3>Direct URLs:</h3>
<textarea
id="image-direct-copy-link-input"
value={this.linksToDirectForm(insertionImageUrls)}
/>
</div>
);
} else {
insertionImageArea = (
<div>
<input
type="file"
onChange={this.handleInsertionImageUpload}
multiple
accept="image/*"
data-max-file-size-mb="25"
/>
</div>
);
}
let imageOptions;
if (version === 'v1') {
imageOptions = (
<div>
<h2>Upload Images</h2>
{insertionImageArea}
<div>
<p>
<em>
To add a cover image for the post, add &nbsp;
<code>cover_image: direct_url_to_image.jpg</code>
&nbsp; to the frontmatter
</em>
</p>
</div>
</div>
);
} else {
imageOptions = (
<div>
<h2>Cover Image</h2>
{mainImageArea}
<h2>Body Images</h2>
{insertionImageArea}
</div>
);
}
return (
<div className="articleform__overlay">
<button
type="button"
className="articleform__exitbutton"
data-content="exit"
onClick={onExit}
>
×
</button>
{uploadError && (
<span className="articleform__uploaderror">{uploadErrorMessage}</span>
)}
{imageOptions}
<div>
<button
type="button"
className="articleform__donebutton"
onClick={onExit}
>
Done
</button>
</div>
</div>
);
}
}
ImageManagement.propTypes = {
onExit: PropTypes.func.isRequired,
onMainImageUrlChange: PropTypes.func.isRequired,
mainImage: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
};

View file

@ -1,28 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const MainImage = ({ mainImage, onEdit }) => (
<div
className="articleform__mainimage"
role="presentation"
onClick={onEdit}
onKeyUp={e => {
if (e.key === 'Enter') {
onEdit(e);
}
}}
>
<img src={mainImage} alt="" />
</div>
);
MainImage.defaultProps = {
onEdit: () => {},
};
MainImage.propTypes = {
mainImage: PropTypes.string.isRequired,
onEdit: PropTypes.func,
};
export default MainImage;

View file

@ -1,133 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
const TextField = ({ label, id, value, onKeyUp }) => {
return (
<div>
<label htmlFor={id}>{label}</label>
<input type="text" value={value} name={id} onKeyUp={onKeyUp} id={id} />
</div>
);
};
TextField.propTypes = {
label: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onKeyUp: PropTypes.func.isRequired,
};
export default class MoreConfig extends Component {
handleSeriesButtonClick = e => {
e.preventDefault();
const { onConfigChange } = this.props;
onConfigChange(e);
};
render() {
const {
onExit,
passedData: {
published = false,
allSeries = [],
canonicalUrl = '',
series = '',
},
onSaveDraft,
onConfigChange,
} = this.props;
let publishedField = '';
let seriesTip = (
<small>
Will this post be part of a series? Give the series a unique name.
(Series visible once it has multiple posts)
</small>
);
if (allSeries.length > 0) {
const seriesNames = allSeries.map(name => {
return (
<button
type="button"
name="series"
onClick={onConfigChange}
value={name}
>
{name}
</button>
);
});
seriesTip = (
<small>
Existing series:
{seriesNames}
</small>
);
}
if (published) {
publishedField = (
<div>
<h4>Danger Zone</h4>
<button type="button" onClick={onSaveDraft}>
Unpublish Post
</button>
</div>
);
}
return (
<div className="articleform__overlay">
<h3>Additional Config/Settings</h3>
<button
type="button"
className="articleform__exitbutton"
data-content="exit"
onClick={onExit}
>
×
</button>
{TextField({
label: 'Canonical URL',
id: 'canonicalUrl',
value: canonicalUrl,
onKeyUp: onConfigChange,
})}
<small>
Change meta tag
{' '}
<code>canonical_url</code>
{' '}
if this post was first
published elsewhere (like your own blog)
</small>
{TextField({
label: 'Series Name',
id: 'series',
value: series,
onKeyUp: onConfigChange,
})}
{seriesTip}
<div>
<button
type="button"
className="articleform__donebutton"
onClick={onExit}
>
Done
</button>
</div>
{publishedField}
</div>
);
}
}
MoreConfig.propTypes = {
onExit: PropTypes.func.isRequired,
passedData: PropTypes.shape({
published: PropTypes.bool.isRequired,
allSeries: PropTypes.array.isRequired,
canonicalUrl: PropTypes.string.isRequired,
series: PropTypes.string.isRequired,
}).isRequired,
onSaveDraft: PropTypes.func.isRequired,
onConfigChange: PropTypes.func.isRequired,
};

View file

@ -1,19 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const Notice = ({ published, version }) => (
<div
className={`articleform__notice articleform__notice--${
published ? 'publishing' : 'draft'
}`}
>
{(published && version === 'v2') ? 'Publishing...' : `Saving ${ version === 'v2' ? 'Draft' : ''}...`}
</div>
);
Notice.propTypes = {
published: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
};
export default Notice;

View file

@ -1,74 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const PublishToggle = ({
previewShowing,
onPreview,
onSaveDraft,
onPublish,
onHelp,
published,
helpShowing,
edited,
version,
onClearChanges,
}) => (
<div className="articleform__buttons">
<button
onClick={onHelp}
type="button"
className={
helpShowing
? 'articleform__buttons--small active'
: 'articleform__buttons--small inactive'
}
>
?
</button>
<button
onClick={onPreview}
type="button"
className={previewShowing ? 'active previewbutt' : 'inactive previewbutt'}
>
{previewShowing ? 'EDIT' : 'PREVIEW'}
</button>
{published || version === 'v1' ? (
''
) : (
<button onClick={onSaveDraft} type="button">
SAVE DRAFT
</button>
)}
<span>
<p style={!edited && { visibility: 'hidden' }}>
New Changes (
<button onClick={onClearChanges} className="clear-button" type="button">
clear
</button>
)
</p>
<button
onClick={onPublish}
className="articleform__buttons--publish"
type="button"
>
{published || version === 'v1' ? 'SAVE CHANGES' : 'PUBLISH'}
</button>
</span>
</div>
);
PublishToggle.propTypes = {
previewShowing: PropTypes.bool.isRequired,
onPreview: PropTypes.func.isRequired,
onSaveDraft: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
onHelp: PropTypes.func.isRequired,
published: PropTypes.bool.isRequired,
helpShowing: PropTypes.bool.isRequired,
edited: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
onClearChanges: PropTypes.func.isRequired,
};
export default PublishToggle;

View file

@ -1,24 +0,0 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import Textarea from 'preact-textarea-autosize';
const Title = ({ onChange, defaultValue, onKeyDown }) => (
<Textarea
className="articleform__title"
type="text"
id="article-form-title"
placeholder="Title"
autoComplete="off"
value={defaultValue}
onInput={onChange}
onKeyDown={onKeyDown}
/>
);
Title.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
onKeyDown: PropTypes.func.isRequired,
};
export default Title;

View file

@ -57,6 +57,7 @@ export const Button = ({
onFocus,
onBlur,
tabIndex,
title,
}) => {
const ComponentName = tagName;
const Icon = icon;
@ -83,6 +84,7 @@ export const Button = ({
onFocus={onFocus}
onBlur={onBlur}
tabIndex={tabIndex}
title={title}
{...otherProps}
>
{contentType !== 'text' && contentType !== 'icon-right' && Icon && (
@ -114,6 +116,7 @@ Button.defaultProps = {
onFocus: undefined,
onBlur: undefined,
tabIndex: undefined,
title: undefined
};
Button.propTypes = {
@ -150,4 +153,5 @@ Button.propTypes = {
onFocus: PropTypes.func,
onBlur: PropTypes.func,
tabIndex: PropTypes.number,
title: PropTypes.string,
};

View file

@ -1,4 +1,4 @@
import { h, Component } from 'preact';
import { h } from 'preact';
import PropTypes from 'prop-types';
import { Button } from '@crayons';
import { defaultChildrenPropTypes } from '../../common-prop-types';
@ -17,68 +17,62 @@ function getAdditionalClassNames({ size, className }) {
return additionalClassNames;
}
export class Modal extends Component {
state = {
visible: true,
};
export const Modal = ({
children,
size = 'default',
className,
title,
overlay,
onClose,
}) => {
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="714d29e78a3867c79b07f310e075e824"
>
<title id="714d29e78a3867c79b07f310e075e824">Close</title>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
render() {
const { visible } = this.state;
const {
children,
size = 'default',
className,
title,
overlay,
} = this.props;
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
return visible ? (
<div
className={`crayons-modal${getAdditionalClassNames({
size,
className,
})}`}
>
<div className="crayons-modal__box">
{title.length > 0 && title && (
<div className="crayons-modal__box__header">
<h2>{title}</h2>
<Button
icon={Icon}
variant="ghost"
contentType="icon"
title="Close"
onClick={(_event) => {
this.setState({ visible: !visible });
}}
/>
</div>
)}
<div className="crayons-modal__box__body">{children}</div>
</div>
{overlay && <div className="crayons-modal__overlay" />}
return (
<div
className={`crayons-modal${getAdditionalClassNames({
size,
className,
})}`}
>
<div className="crayons-modal__box">
{title.length > 0 && title && (
<div className="crayons-modal__box__header">
<h2>{title}</h2>
<Button
icon={Icon}
variant="ghost"
contentType="icon"
title="Close"
onClick={onClose}
/>
</div>
)}
<div className="crayons-modal__box__body">{children}</div>
</div>
) : null;
}
}
{overlay && <div className="crayons-modal__overlay" />}
</div>
);
};
Modal.displayName = 'Modal';
Modal.defaultProps = {
className: undefined,
overlay: true,
onClose: undefined,
};
Modal.propTypes = {
@ -86,5 +80,6 @@ Modal.propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
overlay: PropTypes.bool,
onClose: PropTypes.func,
size: PropTypes.oneOf(['default', 's', 'm']).isRequired,
};

View file

@ -1,9 +1,14 @@
import { h } from 'preact';
import { withKnobs, text, boolean, select } from '@storybook/addon-knobs/react';
import { action } from '@storybook/addon-actions';
import { Modal, Button } from '@crayons';
import '../../storybook-utilities/designSystem.scss';
const commonProps = {
onClose: action('close fired'),
};
export default {
title: '3_Components/Modals',
decorator: [withKnobs],
@ -26,6 +31,7 @@ export const Default = () => (
className={text('className')}
title={text('title', 'This is my Modal title')}
overlay={boolean('overlay', true)}
{...commonProps}
>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse

View file

@ -2,7 +2,7 @@ import { h } from 'preact';
import PropTypes from 'prop-types';
import { organizationPropType } from '../common-prop-types';
const orgOptions = (organizations, organizationId) => {
const orgOptions = (organizations, organizationId, emptyLabel) => {
const orgs = organizations.map((organization) => {
if (organizationId === organization.id) {
return (
@ -16,10 +16,10 @@ const orgOptions = (organizations, organizationId) => {
const nullOrgOption =
organizationId === null ? (
<option value="" selected>
None
{emptyLabel}
</option>
) : (
<option value="">None</option>
<option value="">{emptyLabel}</option>
);
return [nullOrgOption, ...orgs];
@ -28,18 +28,26 @@ const orgOptions = (organizations, organizationId) => {
export const OrganizationPicker = ({
name,
id,
className,
organizations,
organizationId,
onToggle,
emptyLabel
}) => (
<select name={name} id={id} onBlur={onToggle}>
{orgOptions(organizations, organizationId)}
<select name={name} id={id} className={className} onBlur={onToggle}>
{orgOptions(organizations, organizationId, emptyLabel)}
</select>
);
OrganizationPicker.defaultProps = {
emptyLabel: "None"
}
OrganizationPicker.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
className: PropTypes.string.isRequired,
emptyLabel: PropTypes.string,
onToggle: PropTypes.func.isRequired,
organizationId: PropTypes.number.isRequired,
organizations: PropTypes.arrayOf(organizationPropType).isRequired,

View file

@ -15,7 +15,7 @@ function loadForm() {
window.currentUser = currentUser;
window.csrfToken = csrfToken;
const root = document.getElementById('article-form');
const root = document.getElementById('js-article-form');
const { article, organizations, version } = root.dataset;
render(

View file

@ -368,7 +368,14 @@ class Tags extends Component {
render() {
let searchResultsHTML = '';
const { searchResults, selectedIndex, showingRulesForTag } = this.state;
const { classPrefix, defaultValue, maxTags, listing } = this.props;
const {
classPrefix,
defaultValue,
maxTags,
listing,
fieldClassName,
onFocus,
} = this.props;
const { activeElement } = document;
const searchResultsRows = searchResults.map((tag, index) => (
<div
@ -381,7 +388,7 @@ class Tags extends Component {
onKeyDown={this.handleTagEnter}
data-content={tag.name}
>
{tag.name}
<span className={`${classPrefix}__tagname`}>{tag.name}</span>
{tag.rules_html && tag.rules_html.length > 0 ? (
<button
type="button"
@ -432,15 +439,15 @@ class Tags extends Component {
this.textArea = t;
return this.textArea;
}}
className={`${classPrefix}__tags`}
className={`${`${fieldClassName} ${classPrefix}`}__tags`}
name="listing[tag_list]"
placeholder={`${maxTags} tags max, comma separated, no spaces or special characters`}
placeholder={`Add up to ${maxTags} tags...`}
autoComplete="off"
value={defaultValue}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
onBlur={this.handleFocusChange}
onFocus={this.handleFocusChange}
onFocus={onFocus}
/>
{searchResultsHTML}
</div>
@ -453,8 +460,10 @@ Tags.propTypes = {
onInput: PropTypes.func.isRequired,
maxTags: PropTypes.number.isRequired,
classPrefix: PropTypes.string.isRequired,
fieldClassName: PropTypes.string.isRequired,
listing: PropTypes.string.isRequired,
category: PropTypes.string.isRequired,
onFocus: PropTypes.func.isRequired,
};
export default Tags;

View file

@ -91,7 +91,7 @@ module Articles
def user_editor_v1
Article.new(
body_markdown: "---\ntitle: \npublished: false\ndescription: \ntags: \n---\n\n",
body_markdown: "---\ntitle: \npublished: false\ndescription: \ntags: \n//cover_image: https://direct_url_to_image.jpg\n---\n\n",
processed_html: "",
user_id: @user&.id,
)

View file

@ -1,60 +1,101 @@
<div class="articleformcontainer">
<div class="articleform"
id="article-form"
data-article="<%= article.to_json(only: %i[id title cached_tag_list published body_markdown main_image organization_id canonical_url updated_at], methods: %i[series all_series]) %>"
data-organizations="<%= organizations&.to_json(only: %i[id name bg_color_hex text_color_hex], methods: [:profile_image_90]) %>"
data-version="<%= version %>">
<form class="articleform__form articleform__form--<%= version %>">
<% if organizations && organizations.size > 0 %>
<div class="articleform__orgsettings">
Publish under an organization:<select name="article[organization_id]" id="article_publish_under_org">
<%= options_for_select({ "None" => "" }.merge(organizations.pluck(:name, :id).to_h), article.organization_id || "") %>
<style>
body {
padding-top: 0;
}
body > footer,
#top-bar {
display: none
}
.universal-page-content-wrapper {
overflow: unset;
}
</style>
<div id="js-article-form"
data-article="<%= article.to_json(only: %i[id title cached_tag_list published body_markdown main_image organization_id canonical_url updated_at], methods: %i[series all_series]) %>"
data-organizations="<%= organizations&.to_json(only: %i[id name bg_color_hex text_color_hex], methods: [:profile_image_90]) %>"
data-version="<%= version %>">
<form class="crayons-article-form crayons-article-form--<%= version %>">
<div class="crayons-article-form__header">
<a href="/" class="crayons-article-form__logo" aria-label="<%= community_name %> Home"><%= logo_svg %></a>
<div class="crayons-field__label">
<span class="hidden s:inline-block">Write a new post</span>
<% if organizations && organizations.size > 0 %>
<select name="article[organization_id]" id="article_publish_under_org" class="crayons-select w-auto ml-2 mt-0">
<%= options_for_select({ "Personal" => "" }.merge(organizations.pluck(:name, :id).to_h), article.organization_id || "") %>
</select>
</div>
<% end %>
<% if version == "v2" %>
<% if article.main_image.present? %>
<div class="articleform__mainimage"><img src="<%= article.main_image %>"></div>
<% end %>
<input class="articleform__title articleform__titlepreview" type="text" value="<%= article.title %>" placeholder="Title" name="title" />
<div class="articleform__detailfields">
<div class="articleform__tagswrapper">
<input type="text" class="articleform__tags" placeholder="4 tags max, comma separated, no spaces or special characters" name="tags" value="<%= article.cached_tag_list %>" />
</div>
<button class="articleform__detailsButton articleform__detailsButton--image"></button>
<button class="articleform__detailsButton articleform__detailsButton--moreconfig"></button>
</div>
<% end %>
<textarea class="articleform__body" placeholder="Body Markdown" name="body_markdown"><%= article.body_markdown %></textarea>
<div>
<button class="articleform__detailsButton articleform__detailsButton--image articleform__detailsButton--bottom"></button>
<% if version == "v2" %>
<button class="articleform__detailsButton articleform__detailsButton--moreconfig articleform__detailsButton--bottom"></button>
<% end %>
</div>
<div class="articleform__buttons">
<span style="visibility:hidden;">
<p>New Changes <button class="clear-button">clear</button></p>
<button class="articleform__buttons--publish">SAVE CHANGES</button>
</span>
<div class="crayons-article-form__tabs crayons-tabs ml-auto">
<button class="crayons-tabs__item crayons-tabs__item--current" type="button">Edit</button><button class="crayons-tabs__item " type="button">Preview</button>
</div>
<div class="crayons-article-form__close">
<a href="/" class="crayons-btn crayons-btn--ghost crayons-btn--icon" title="Close the editor">
<%= inline_svg_tag("x.svg", aria: true, class: "crayons-icon", title: "Close") %>
</a>
</div>
</form>
<div style="display:none">
<% if version == "v2" %>
<%= render "pages/editor_guide_text", version: "2" %>
<% else %>
<%= render "pages/editor_guide_text", version: "1" %>
<% end %>
</div>
</div>
<div class="crayons-article-form__main">
<div class="crayons-card crayons-article-form__main__content">
<% if version == "v2" %>
<div>
<div class="crayons-article-form__cover" role="presentation">
<% if article.main_image.present? %>
<img src="<%= article.main_image %>" width="250" height="105" alt="Post cover" class="crayons-article-form__cover__image">
<% end %>
<button class="crayons-btn crayons-btn--outlined mr-2">
<label for="cover-image-input">
<% if article.main_image.present? %>Change<% else %>Add a cover image<% end %>
</label>
<input id="cover-image-input" type="file" accept="image/*" class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer" data-max-file-size-mb="25">
</button>
</div>
<div class="crayons-article-form__title">
<textarea class="crayons-textfield crayons-textfield--ghost fs-3xl s:fs-4xl l:fs-5xl fw-bold s:fw-heavy lh-tight" type="text" id="article-form-title" placeholder="New post title here..." autocomplete="off" style="height: 60px; "><%= article.title %></textarea>
</div>
<div class="crayons-article-form__tagsfield">
<input type="text" class="crayons-textfield crayons-textfield--ghost ff-accent" placeholder="Add up to 4 tags..." name="tags" value="<%= article.cached_tag_list %>" />
</div>
</div>
<% end %>
<div class="crayons-article-form__body">
<div class="crayons-article-form__toolbar">
<button class="crayons-btn crayons-btn--ghost crayons-btn--icon-left fw-normal" type="button">
<%= (inline_svg_tag("image.svg", aria: true, class: "crayons-icon", title: "Image") + "Upload image").delete!("\n").html_safe %>
<input type="file" id="image-upload-field" class="w-100 h-100 absolute left-0 right-0 top-0 bottom-0 overflow-hidden opacity-0 cursor-pointer" multiple accept="image/*" data-max-file-size-mb="25" />
</button>
</div>
<textarea class="crayons-article-form__body__field crayons-textfield crayons-textfield--ghost fs-l ff-accent" id="article_body_markdown" placeholder="Write your post content here..." name="body_markdown"><%= article.body_markdown %></textarea>
</div>
</div>
<div class="crayons-article-form__main_aside"></div>
</div>
<div class="crayons-article-form__footer">
<button class="crayons-btn mr-2" type="button">Publish</button>
<button class="crayons-btn crayons-btn--secondary mr-2" type="button">Save draft</button>
<div class="relative">
<button class="crayons-btn crayons-btn--ghost crayons-btn--icon" type="button" title="Post options">
<%= inline_svg_tag("cog.svg", aria: true, class: "crayons-icon", title: "Options") %>
</button>
</div>
</div>
</form>
</div>
<div class="hidden">
<%= render "pages/editor_markdown_help" %>
<%= render "pages/editor_liquid_help" %>
<%= render "pages/editor_frontmatter_help" %>
</div>
<script async>
<%== RunkitTag.script %>
</script>
<style>
#footer-container {
display: none
}
</style>

View file

@ -0,0 +1,29 @@
<div id="editor-frontmatter-help">
<div class="crayons-article">
<div class="crayons-article__body">
<div class="crayons-notice crayons-notice--info">
<p><strong>We have two editor versions</strong>. If you'd prefer to edit title and tags etc. as separate fields, switch to the "rich + markdown" option in <a href="<%= user_settings_path(tab: :ux) %>">Settings &raquo; UX</a>. Otherwise, continue...</p>
</div>
<ul>
<li>This editor is a markdown editor that uses <a href="https://jekyllrb.com/docs/frontmatter/">Jekyll front matter</a>.</li>
<li>Most of the time, you can write inline HTML directly into your posts.</li>
<li>We support native Liquid tags and created some fun custom ones, too! Trying embedding a Tweet or GitHub issue in your post.</li>
<li>Links to unpublished posts are shareable for feedback/review.</li>
<li>When you're ready to publish, set the published variable to <strong>true.</strong></li>
</ul>
<h3>Front Matter</h3>
<p>Custom variables set for each post, located between the triple-dashed lines in your editor. Here is a list of possibilities:</p>
<ul>
<li><strong>title:</strong> the title of your article</li>
<li><strong>published:</strong> boolean that determines whether or not your article is published</li>
<li><strong>description:</strong> description area in Twitter cards and open graph cards</li>
<li><strong>tags:</strong> max of four tags, needs to be comma-separated</li>
<li><strong>canonical_url:</strong> link for the canonical version of the content</li>
<li><strong>cover_image:</strong> cover image for post, accepts a URL. <br>The best size is 1000 x 420.</li>
<li><strong>series:</strong> post series name.</li>
</ul>
</div>
</div>
</div>

View file

@ -277,7 +277,7 @@
<h3><strong>Next Tech Embed</strong></h3>
<p>All you need is the share URL for your sandbox. You can get the share URL by clicking
the "Share" button in the top right when the sandbox is open.</p>
<p><img src="https://thepracticaldev.s3.amazonaws.com/i/r449xp8cay3383i139qv.png" /></p>
<p><img src="https://thepracticaldev.s3.amazonaws.com/i/r449xp8cay3383i139qv.png" alt="Share Replit sandbox" /></p>
<code>{% nexttech https://nt.dev/s/6ba1fffbd09e %}</code>
<h3><strong>Instagram Embed</strong></h3>
<p>All you need is the Instagram post <code>id</code> from the URL:</p>

View file

@ -0,0 +1,286 @@
<div id="editor-liquid-help">
<div class="crayons-article">
<div class="crayons-article__body">
<p>We support native <a href="https://shopify.github.io/liquid/" target="_blank" rel="noopener">Liquid tags</a> in our editor, but have created our own custom tags listed below:</p>
<h3><%= community_name %> Article/Post Embed</h3>
<p>All you need is the full link of the article:</p>
<code>{% link https://dev.to/kazz/boost-your-productivity-using-markdown-1be %}</code>
<p>You can also use the slug like this:</p>
<code>{% link kazz/boost-your-productivity-using-markdown-1be %}</code>
<p>You can also use the alias post instead of link like this:</p>
<code>{% post https://dev.to/kazz/boost-your-productivity-using-markdown-1be %}</code>
<p>or this:</p>
<code>{% post kazz/boost-your-productivity-using-markdown-1be %}</code>
<h3><%= community_name %> User Embed</h3>
<p>All you need is the <%= community_name %> username:</p>
<code>{% user jess %}</code>
<h3><%= community_name %> Tag Embed</h3>
<p>All you need is the tag name:</p>
<code>{% tag git %}</code>
<h3><%= community_name %> Comment Embed</h3>
<p>All you need is the
<code>ID</code> at the end of a comment URL. To get the comment link, click either the timestamp or the menu button in the top right corner on a comment and then click "Permalink". Here's an example:
</p>
<code>{% devcomment 2d1a %}</code>
<h3><%= community_name %> Podcast Episode Embed</h3>
<p>All you need is the full link of the podcast episode:</p>
<code>{% podcast https://dev.to/basecspodcast/s2e2--queues-irl %}</code>
<h3><%= community_name %> Listing Embed</h3>
<p>All you need is the full link of the listing:</p>
<code>{% listing https://dev.to/listings/collabs/dev-is-open-source-823 %}</code>
<p>You can also use the category and slug like this:</p>
<code>{% listing collabs/dev-is-open-source-823 %}</code>
<p>Note: Expired listings will raise an error. Make sure the listing is published or recently bumped.</p>
<h3>Twitter Embed</h3>
<p>Using the Twitter Liquid tag will allow the tweet to pre-render from the server, providing your reader with a better experience. All you need is the tweet
<code>id</code> from the url.</p>
<code>{% twitter 834439977220112384 %}</code>
<h3>Glitch embed</h3>
<p>All you need is the Glitch project slug</p>
<code>{% glitch earthy-course %}</code>
<p>There are several
<a href="https://glitch.com/help/how-can-i-customize-a-glitch-app-embed/">optional attributes</a> you can use in your tag, just add them after the id, separated by spaces.
</p>
<ul>
<li><code>app</code> Shows the app preview without the code.<br>
<code>{% glitch earthy-course app %}</code>
</li>
<li><code>code</code> Shows the code without the app preview.<br>
<code>{% glitch earthy-course code %}</code></li>
<li><code>preview-first</code> Swap panes: Show the app preview on the left and the code on the right.<br>
<code>{% glitch earthy-course preview-first %}</code></li>
<li><code>no-attribution</code> Hides the avatar of the creator(s).<br>
<code>{% glitch earthy-course no-attribution %}</code></li>
<li><code>no-files</code> Hides the file browser.<br>
<code>{% glitch earthy-course no-files %}</code></li>
<li><code>file</code> Lets you choose which file to display in the code panel. Defaults to index.html.<br>
<code>{% glitch earthy-course file=script.js %}</code></li>
</ul>
<h3>GitHub Repo Embed</h3>
<p>All you need is the GitHub username and repo:</p>
<code>{% github thepracticaldev/dev.to %}</code>
<dl>
<dt><code>no-readme</code></dt>
<dd>
You can add a no-readme option to your GitHub tag to hide the readme file from the preview.<br>
<code>{% github thepracticaldev/dev.to no-readme %}</code>
</dd>
</dl>
<h3>GitHub Issue, Pull request or Comment Embed</h3>
<p>All you need is the GitHub issue, PR or comment URL:</p>
<code>{% github https://github.com/thepracticaldev/dev.to/issues/9 %}</code>
<h3>GitHub Gist Embed</h3>
<p>All you need is the gist link:</p>
<code>
{% gist https://gist.github.com/CristinaSolana/1885435 %}
</code>
<dl>
<dt><code>Single File Embed</code></dt>
<dd>
<p>You can choose to embed a single gist file. <br>
<code>{% gist https://gist.github.com/CristinaSolana/1885435 file=gistfile1.md %}</code></p>
</dd>
<dt><code>Specific Version Embed</code></dt>
<dd>
<p>You can choose to embed a specific version of a gist file. All you need
the link and the commit hash for that specific version. <br>
The format is <code>{% gist [gist-link]/[commit-hash] %}</code> <br>
e.g. <br>
<code>{% gist https://gist.github.com/suntong/3a31faf8129d3d7a380122d5a6d48ff6/f77d01e82defbf736ebf4879a812cf9c916a9252 %}</code></p>
</dd>
<dt><code>Specific Version File Embed</code></dt>
<dd>
<p>You can choose to embed a specific version of a gist file. All you need
the link, the filename and the commit hash for that specific version . <br>
The format is <code>{% gist [gist-link]/[commit-hash] file=[filename] %}</code> <br>
e.g. <br>
<code>
{% gist https://gist.github.com/suntong/3a31faf8129d3d7a380122d5a6d48ff6/f77d01e82defbf736ebf4879a812cf9c916a9252 file=Images.tmpl %}
</code></p>
</dd>
</dl>
<h3>GitPitch Embed</h3>
<p>All you need is the GitPitch link:</p>
<code>
{% gitpitch https://gitpitch.com/gitpitch/in-60-seconds %}
</code>
<h3>Video Embed</h3>
<p>All you need is the <code>id</code> from the URL.</p>
<ul>
<li><strong>YouTube:</strong> <code>{% youtube dQw4w9WgXcQ %}</code></li>
<li><strong>Vimeo:</strong> <code>{% vimeo 193110695 %}</code></li>
</ul>
<h3>Medium Embed</h3>
<p>Just enter the full URL of the Medium article you are trying to embed.</p>
<code>{% medium https://medium.com/s/story/boba-science-how-can-i-drink-a-bubble-tea-to-ensure-that-i-dont-finish-the-tea-before-the-bobas-7fc5fd0e442d %}</code>
<h3>SlideShare Embed</h3>
<p>All you need is the SlideShare <code>key</code>:</p>
<code>{% slideshare rdOzN9kr1yK5eE %}</code>
<h3>CodePen Embed</h3>
<p>All you need is the full CodePen <code>link</code>, ending in the pen ID code, as follows:</p>
<code>{% codepen https://codepen.io/twhite96/pen/XKqrJX %}</code>
<dl>
<dt><code>default-tab</code></dt>
<dd>
Add default-tab parameter to your CodePen embed tag. Default to <i>result</i><br>
<code>{% codepen https://codepen.io/twhite96/pen/XKqrJX default-tab=js,result %}</code>
</dd>
</dl>
<h3>Kotlin Playground</h3>
<p>To create a runnable kotlin snippet, create a Kotlin Snippet at <a href="https://play.kotlinlang.org">https://play.kotlinlang.org</a></p>
<p>Go to <code>Share</code> dialog and copy the full <code>link</code> from the <code>Medium</code> tab. Use it as follows:</p>
<code>{% kotlin https://pl.kotl.in/owreUFFUG?theme=darcula&from=3&to=6&readOnly=true %}</code>
<h3>RunKit Embed</h3>
<p>Put executable code within a runkit liquid block, as follows:</p>
<pre>{% runkit<br>// hidden setup JavaScript code goes in this preamble area<br>const hiddenVar = 42<br>%}<br>// visible, reader-editable JavaScript code goes here<br>console.log(hiddenVar)<br>{% endrunkit %}<br></pre>
<h3>KaTeX Embed</h3>
<p>Place your mathematical expression within a KaTeX liquid block, as follows:</p>
<pre>{% katex %}<br> c = \pm\sqrt{a^2 + b^2}<br>{% endkatex %}<br></pre>
<p>To render KaTeX inline add the "inline" option:</p>
<pre>{% katex inline %}<br> c = \pm\sqrt{a^2 + b^2}<br>{% endkatex %}<br></pre>
<h3>Stackblitz Embed</h3>
<p>All you need is the ID of the Stackblitz:</p>
<code>{% stackblitz ball-demo %}</code>
<dl>
<dt><code>Default view</code></dt>
<dd>
You can change the default view, the options are <i>both</i>, <i>preview</i>, <i>editor</i>. Defaults to
<i>both</i><br>
<code>{% stackblitz ball-demo view=preview %}</code>
</dd>
<dt><code>Default file</code></dt>
<dd>
You can change the default file you want your embed to point to<br>
<code>{% stackblitz ball-demo file=style.css %}</code>
</dd>
</dl>
<h3>CodeSandbox Embed</h3>
<p>All you need is the ID of the Sandbox:</p>
<code>{% codesandbox ppxnl191zx %}</code>
<p>Of CodeSandbox's many
<a href="https://codesandbox.io/docs/embedding#embed-options">optional attributes</a>, the following are supported by using them in your tag, just add them after the id, separated by spaces.
</p>
<dl>
<dt><code>initialpath</code></dt>
<dd>
Which url to initially load in address bar.<br>
<code>{% codesandbox ppxnl191zx initialpath=/initial/load/path %}</code>
</dd>
<dt><code>module</code></dt>
<dd>
Which module to open by default.<br>
<code>{% codesandbox ppxnl191zx module=/path/to/module %}</code>
</dd>
<dt><code>runonclick</code></dt>
<dd>
Delays when code is ran if <code>1</code> <br>
<code>{% codesandbox ppxnl191zx runonclick=1 %}</code>
</dd>
</dl>
<h3>JSFiddle Embed</h3>
<p>All you need is the full JSFiddle <code>link</code>, ending in the fiddle ID code, as follows:</p>
<code>{% jsfiddle https://jsfiddle.net/link2twenty/v2kx9jcd %}</code>
<dl>
<dt><code>Custom tabs</code></dt>
<dd>
You can add a custom tab order to you JSFiddle embed tag. Defaults to <i>js,html,css,result</i><br>
<code>{% jsfiddle https://jsfiddle.net/webdevem/Q8KVC result,html,css %}</code>
</dd>
</dl>
<h3>JSitor Liquid Tag</h3>
<p>
To use JSitor liquid tag you can use the JSitor full <code>link</code>, with or without the parameters
</p>
<code>{% jsitor https://jsitor.com/embed/B7FQ5tHbY %}</code>
<br />
<code>{% jsitor https://jsitor.com/embed/B7FQ5tHbY?html&js&css&result&light %}</code>
<p>
Other options to use JSitor liquid tag is just by its ID, you can add it with or without the parameters
</p>
<code>{% jsitor B7FQ5tHbY %}</code>
<br />
<code>{% jsitor B7FQ5tHbY?html&js&css&result&light %}</code>
<h3>repl.it Embed</h3>
<p>All you need is the URL after the domain name:</p>
<code>{% replit @WigWog/PositiveFineOpensource %}</code>
<h3>Next Tech Embed</h3>
<p>All you need is the share URL for your sandbox. You can get the share URL by clicking
the "Share" button in the top right when the sandbox is open.</p>
<p><img src="https://thepracticaldev.s3.amazonaws.com/i/r449xp8cay3383i139qv.png" alt="Share Replit sandbox" /></p>
<code>{% nexttech https://nt.dev/s/6ba1fffbd09e %}</code>
<h3>Instagram Embed</h3>
<p>All you need is the Instagram post <code>id</code> from the URL:</p>
<code>{% instagram BXgGcAUjM39 %}</code>
<h3>Speakerdeck Tag</h3>
<p>All you need is the data-id code from the embed link:</p>
<pre><span style="color: gray;"># Given this embed link:</span><br>&lt;script async class="speakerdeck-embed"<br> data-id="<span style='color: orange;'>7e9f8c0fa0c949bd8025457181913fd0</span>"<br> data-ratio="1.77777777777778" src="//speakerdeck.com/assets/embed.js"&gt;&lt;/script&gt;</pre>
<pre>{% speakerdeck <span style="color: orange;">7e9f8c0fa0c949bd8025457181913fd0</span> %}</pre>
<h3>Soundcloud Embed</h3>
<p>Just enter the full URL of the Soundcloud track you are trying to embed.</p>
<code>{% soundcloud https://soundcloud.com/user-261265215/dev-to-review-episode-1 %}</code>
<h3>Spotify Embed</h3>
<p>
Enter the Spotify URI of the Spotify track / playlist /
album / artist / podcast episode you are trying to embed.
</p>
<pre>{% spotify <span style="color: #1DB954;">spotify:episode:5V4XZWqZQJvbddd31n56mf</span> %}</pre>
<h3>Blogcast Tag</h3>
<p>All you need is the article id code from the embed code:</p>
<pre>{% blogcast <span style="color: orange;">1234</span> %}</pre>
<h3>Parler Tag</h3>
<p>Enter the full url of the Parler.io audio file you want to embed. </p>
<pre>{% parler https://www.parler.io/audio/73240183203/d53cff009eac2ab1bc9dd8821a638823c39cbcea.7dd28611-b7fc-4cf8-9977-b6e3aaf644a1.mp3 %}</pre>
<h3>Stack Exchange / Stack Overflow Tag</h3>
<p>
You'll need the question or answer's ID code, and the site. When using <code>{% stackoverflow %}</code> as the tag, the site will default to Stack Overflow.
For example:
<br>
<a href="https://stackoverflow.com/questions/24789130/colors-in-irb-rails-console">https://stackoverflow.com/questions/24789130/colors-in-irb-rails-console</a>
<ul>
<li>The question ID is: <code style="color: aquamarine; background-color: black;">24789130</code></li>
</ul>
<pre>{% stackoverflow <span style="color: aquamarine;">24789130</span> %}</pre>
</p>
<p>
For other Stack Exchange network sites, you'll need to provide the site's name and use <code>{% stackexchange %}</code> as the tag. For example:
<br>
<a href="https://diy.stackexchange.com/questions/169988/base-for-refrigerator-wine-shelf">https://diy.stackexchange.com/questions/169988/base-for-refrigerator-wine-shelf</a>
<ul>
<li>The question ID is: <code style="color: aquamarine; background-color: black;">169988</code></li>
<li>The site is <code style="color: orange; background-color: black;">diy</code></li>
</ul>
<pre>{% stackexchange <span style="color: aquamarine;">169988</span> <span style="color: orange;">diy</span> %}</pre>
</p>
<p>
For answers, you can get the answer's ID code by from the answer's "Share" link. For example:
<br>
<a href="https://diy.stackexchange.com/a/170185">https://diy.stackexchange.com/a/170185</a>
<ul>
<li>The answer ID is: <code style="color: aquamarine; background-color: black;">170185</code></li>
<li>The site is <code style="color: orange; background-color: black;">diy</code></li>
</ul>
<pre>{% stackexchange <span style="color: aquamarine;">170185</span> <span style="color: orange;">diy</span> %}</pre>
</p>
<h3>Wikipedia Embed</h3>
<p>Enter the full URL of the Wikipedia article you want to embed, with or without the anchor.</p>
<p>
<code>{% wikipedia https://en.wikipedia.org/wiki/Wikipedia %}</code>
<br />
<code>{% wikipedia https://en.wikipedia.org/wiki/Wikipedia#Diversity %}</code>
</p>
<h3>Asciinema Embed</h3>
<p>All you need is the Asciinema id:</p>
<code>{% asciinema 239367 %}</code>
<h3>Parsing Liquid Tags as a Code Example</h3>
<p>To parse Liquid tags as code, simply wrap it with a single backtick or triple backticks.</p>
<p><code>`{% mytag %}{{ site.SOMETHING }}{% endmytag %}`</code></p>
<p>One specific edge case is with using the <code>raw</code> tag. To properly escape it, use this format:
</p>
<p><code>`{% raw %}{{site.SOMETHING }} {% ``endraw`` %}`</code></p>
</div>
</div>
</div>

View file

@ -0,0 +1,59 @@
<div id="editor-markdown-help">
<div class="crayons-article">
<div class="crayons-article__body">
<p>Below are some examples of commonly used markdown syntax. If you want to dive deeper, check out
<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Here-Cheatsheet" target="_blank" rel="noopener">this cheat sheet.</a>
</p>
<h3>Bold & Italic</h3>
<p><em>Italics</em>: <code>*asterisks* or _underscores_</code></p>
<p><strong>Bold</strong>: <code>**double asterisks** or __double underscores__</code></p>
<h3>Links</h3>
<p><a href="<%= app_url %>">I'm an inline link</a>: <code>[I'm an inline link](put-link-here)</code></p>
<p><a name="anchored">Anchored links</a> (For things like a Table of Contents)</p>
<pre>
## Table Of Contents
* [Chapter 1](#chapter-1)
* [Chapter 2](#chapter-2)
### Chapter 1 <%= "<a name=\"chapter-1\"></a>" %>
</pre>
<h3>Inline Images</h3>
<p>
When adding GIFs to posts and comments, please note that there is a limit of 200 megapixels per frame/page.
<img src="https://res.cloudinary.com/practicaldev/image/fetch/s--OsLaFSo9--/c_fill,f_auto,fl_progressive,h_220,q_auto,w_220/https://thepracticaldev.s3.amazonaws.com/uploads/user/profile_image/31047/af153cd6-9994-4a68-83f4-8ddf3e13f0bf.jpg" alt="example image, with sloan" />
</p>
<pre>![Alt text of image](put-link-to-image-here)</pre>
<figcaption> You can even add a caption using the HTML <code>figcaption</code> tag!</figcaption>
<h3>Headers</h3>
<p>Add a header to your post with this syntax:</p>
<pre># One '#' for a h1 header<br>## Two '#'s for a h2 header<br>...<br>###### Six '#'s for a h6 header</pre>
<h1>One '#' for a h1 header</h1>
<h2>Two '#'s for a h2 header</h2>
<h3>Three '#'s for a h3 header</h3>
<h4>Four '#'s for a h4 header</h4>
<h5>Five '#'s for a h5 header</h5>
<h6>Six '#'s for a h6 header</h6>
<h3>Author Notes/Comments</h3>
<p>Add some hidden notes/comments to your article with this syntax:</p>
<pre>&lt;!-- This won't show up in the content! --&gt;</pre>
<h3>Common Gotchas</h3>
<p>
Lists are written just like any other Markdown editor.
If you're adding an image in between numbered list, though, be sure to tab the image,
otherwise it'll restart the number of the list.
Here's an example of what to do:
<img src="https://res.cloudinary.com/practicaldev/image/fetch/s--HjVUshkb--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://thepracticaldev.s3.amazonaws.com/i/8l29vxiir8k5d097o9o8.png"
alt="example image of writing lists with images in Markdown">
</p>
<p>
Here's the <a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Here-Cheatsheet" target="_blank" rel="noopener">Markdown cheatsheet</a> again for reference.
</p>
</div>
</div>
</div>

View file

@ -1,5 +1,8 @@
// Consistent timezone for testing.
// This does not work on windows, see https://github.com/nodejs/node/issues/4230
/* global process, module */
process.env.TZ = 'UTC';
module.exports = {
@ -14,8 +17,8 @@ module.exports = {
],
coverageThreshold: {
global: {
statements: 39,
branches: 35,
statements: 38,
branches: 34,
functions: 34,
lines: 39,
},

View file

@ -213,7 +213,7 @@ RSpec.describe "Articles", type: :request do
it "shows v1 if article has frontmatter" do
article = create(:article, user_id: user.id)
get "#{article.path}/edit"
expect(response.body).to include("articleform__form--v1")
expect(response.body).to include("crayons-article-form--v1")
end
end

View file

@ -129,7 +129,7 @@ RSpec.describe Articles::Builder, type: :service do
context "when user_editor_v1" do
let(:correct_attributes) do
{
body_markdown: "---\ntitle: \npublished: false\ndescription: \ntags: \n---\n\n",
body_markdown: "---\ntitle: \npublished: false\ndescription: \ntags: \n//cover_image: https://direct_url_to_image.jpg\n---\n\n",
processed_html: "",
user_id: user.id
}

View file

@ -16,14 +16,14 @@ RSpec.describe "Creating an article with the editor", type: :system do
it "renders the page", js: true, percy: true do
visit new_path
fill_in "article_body_markdown", with: template
click_button "SAVE CHANGES"
click_button "Save changes"
Percy.snapshot(page, name: "Creating an article: shows the title")
end
it "creates a new article", js: true, retry: 3 do
visit new_path
fill_in "article_body_markdown", with: template
click_button "SAVE CHANGES"
click_button "Save changes"
expect(page).to have_selector("header h1", text: "Sample Article")
end
@ -31,7 +31,7 @@ RSpec.describe "Creating an article with the editor", type: :system do
it "creates a new article with a Runkit tag" do
visit new_path
fill_in "article_body_markdown", with: template_with_runkit_tag
click_button "SAVE CHANGES"
click_button "Save changes"
expect_runkit_tag_to_be_active
end
@ -39,15 +39,15 @@ RSpec.describe "Creating an article with the editor", type: :system do
it "previews article with a Runkit tag and creates it" do
visit new_path
fill_in "article_body_markdown", with: template_with_runkit_tag
click_button "PREVIEW"
click_button "Preview"
expect_runkit_tag_to_be_active
click_button "EDIT"
click_button "Edit"
expect_no_runkit_tag_to_be_active
click_button "SAVE CHANGES"
click_button "Save changes"
expect_runkit_tag_to_be_active
end

View file

@ -12,38 +12,35 @@ RSpec.describe "Editing with an editor", type: :system, js: true do
it "renders the page when user previews their changes", percy: true do
visit "/#{user.username}/#{article.slug}/edit"
fill_in "article_body_markdown", with: template.gsub("Suspendisse", "Yooo")
click_button("PREVIEW")
click_button("Preview")
Percy.snapshot(page, name: "Editing an article: shows the title")
end
it "user previews their changes" do
visit "/#{user.username}/#{article.slug}/edit"
fill_in "article_body_markdown", with: template.gsub("Suspendisse", "Yooo")
click_button("PREVIEW")
click_button("Preview")
expect(page).to have_text("Yooo")
expect(find(".active").text).to have_text("EDIT")
end
it "user updates their post" do
visit "/#{user.username}/#{article.slug}/edit"
fill_in "article_body_markdown", with: template.gsub("Suspendisse", "Yooo")
click_button("SAVE CHANGES")
click_button("Save changes")
expect(page).to have_text("Yooo")
end
it "user unpublishes their post" do
visit "/#{user.username}/#{article.slug}/edit"
fill_in "article_body_markdown", with: template.gsub("true", "false")
click_button("SAVE CHANGES")
click_button("Save changes")
expect(page).to have_text("Unpublished Post.")
end
it "renders the page when user unpublishes their post", percy: true do
visit "/#{user.username}/#{article.slug}/edit"
fill_in "article_body_markdown", with: template.gsub("true", "false")
click_button("SAVE CHANGES")
click_button("Save changes")
Percy.snapshot(page, name: "Editing an article: shows when unpublished")
end
end

View file

@ -26,7 +26,7 @@ RSpec.describe "Using the editor", type: :system do
before do
fill_markdown_with(read_from_file(raw_text))
page.execute_script("window.scrollTo(0, -100000)")
find("button", text: /\APREVIEW\z/).click
find("button", text: /\APreview\z/).click
end
after do
@ -38,7 +38,7 @@ RSpec.describe "Using the editor", type: :system do
end
it "fills out form with rich content and click preview" do
article_body = find("div.body")["innerHTML"]
article_body = find("div.crayons-article__body")["innerHTML"]
article_body.gsub!(/"https:\/\/res\.cloudinary\.com\/.{1,}"/, "cloudinary_link")
Approvals.verify(article_body, name: "user_preview_article_body", format: :html)
@ -48,13 +48,13 @@ RSpec.describe "Using the editor", type: :system do
describe "Submitting an article", js: true do
it "renders the page", percy: true do
fill_markdown_with(read_from_file(raw_text))
find("button", text: /\ASAVE CHANGES\z/).click
find("button", text: /\ASave changes\z/).click
Percy.snapshot(page, name: "Using the editor: submit an article")
end
it "fill out form and submit" do
fill_markdown_with(read_from_file(raw_text))
find("button", text: /\ASAVE CHANGES\z/).click
find("button", text: /\ASave changes\z/).click
article_body = find(:xpath, "//div[@id='article-body']")["innerHTML"]
article_body.gsub!(/"https:\/\/res\.cloudinary\.com\/.{1,}"/, "cloudinary_link")
@ -63,7 +63,7 @@ RSpec.describe "Using the editor", type: :system do
it "user write and publish an article" do
fill_markdown_with(template.gsub("false", "true"))
find("button", text: /\ASAVE CHANGES\z/).click
find("button", text: /\ASave changes\z/).click
["Sample Article", template[-200..], "test"].each do |text|
expect(page).to have_text(text)
end
@ -72,7 +72,7 @@ RSpec.describe "Using the editor", type: :system do
context "without a title", js: true do
before do
fill_markdown_with(template.gsub("Sample Article", ""))
find("button", text: /\ASAVE CHANGES\z/).click
find("button", text: /\ASave changes\z/).click
end
it "renders the page", percy: true do
@ -80,7 +80,7 @@ RSpec.describe "Using the editor", type: :system do
end
it "shows a message that the title cannot be blank" do
expect(page).to have_text(/title: can't be blank/)
expect(page).to have_text(/title: can't be blank/)
end
end
end