chore: Update framework version

This commit is contained in:
Robert Prehn 2021-10-19 16:07:30 -05:00
commit 5d3a8dbd16
212 changed files with 27958 additions and 21371 deletions

3
.dialyzer_ignore.exs Normal file
View file

@ -0,0 +1,3 @@
[
{"lib/i18n.ex:1:pattern_match_cov The pattern pattern <_@1, _@2, _@3> can never match the type, because it is covered by previous clauses."}
]

View file

@ -2,3 +2,6 @@ cover
.elixir_ls
*.dump
.journal-*
.git
.priv/mnesia*

10
.gitignore vendored
View file

@ -33,6 +33,9 @@ node_modules
# this depending on your deployment strategy.
/priv/static/
# Temp files generated by tests
/apps/*/priv/test/
# Mnesia DBs
/apps/*/priv/mnesia*
/priv/mnesia*
@ -44,3 +47,10 @@ config/*.secret.exs
# Temporary CI build file
build.env
# CI metrics file
metrics.txt
# Cypress Artifacts
/apps/*/cypress/videos/

View file

@ -13,6 +13,9 @@ variables:
# fetch & clean the repo rather than completely cloning (faster)
GIT_STRATEGY: fetch
include:
- .gitlab/ci/.cypress.yml
# Test stage. Runs various tests and speculatively builds docker image in
# parallel, in case the build passes.
.test_template: &test_template
@ -24,9 +27,11 @@ variables:
- deps/
services:
- name: postgres:12
script: script/cibuild
script:
- apk add git
- script/cibuild
test:
test_1.10.4:
<<: *test_template
image: "elixir:1.10.4-alpine"
@ -34,9 +39,47 @@ test_1.11.4:
<<: *test_template
image: "elixir:1.11.4-alpine"
test_1.12.1:
test:
<<: *test_template
image: "elixir:1.12.1-alpine"
script:
- apk add git
- script/cibuild covered
artifacts:
paths:
- cover/excoveralls.json
credo:
stage: test
image: "elixir:1.12.1-alpine"
cache:
key: "test_1.12.1"
paths:
- _build/
- deps/
script:
- apk add git
- mix local.hex --force
- mix local.rebar --force
- mix deps.get
- mix deps.compile
- mix credo --all
stylelint:
stage: test
image: "node:16"
script:
- cd apps/app/assets/
- npm install
- npx stylelint **/*.css
prettier:
stage: test
image: "node:16"
script:
- cd apps/app/assets/
- npm install
- npx prettier --check **/*.js
build_image_for_commit:
stage: test
@ -56,10 +99,31 @@ build_image_for_commit:
script:
- script/ci-docker-build
report_coverage:
stage: deploy_tags
needs: ['test']
image: "elixir:1.12.1-alpine"
script:
- mix local.hex --force
- script/coverage-json-to-metrics
- script/coverage-json-to-cobertura
artifacts:
reports:
metrics: metrics.txt
cobertura: cover/cobertura.xml
# If tests pass, tag the commit and update package versions
deploy_to_tags:
stage: deploy_tags
needs: ['test', 'build_image_for_commit']
needs:
- test
- test_1.10.4
- test_1.11.4
- build_image_for_commit
- cypress
- credo
- prettier
- stylelint
image: "node:16"
only:
- master
@ -116,7 +180,7 @@ deploy_commit_image_to_tag:
.dependabot_gitlab: &dependabot_gitlab
image:
name: docker.io/andrcuns/dependabot-gitlab:0.4.3
name: docker.io/andrcuns/dependabot-gitlab:0.9.7
entrypoint: [""]
variables:
GIT_STRATEGY: none
@ -150,6 +214,7 @@ npm-assets:
- $PACKAGE_MANAGER_SET =~ /(\bnpm|yarn\b)/
mix-admin:
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
extends: .dependabot_gitlab
variables:
DIRECTORY: "/apps/admin"
@ -159,6 +224,7 @@ mix-admin:
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
mix-app:
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
extends: .dependabot_gitlab
variables:
DIRECTORY: "/apps/app"
@ -168,6 +234,7 @@ mix-app:
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
mix-core:
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
extends: .dependabot_gitlab
variables:
DIRECTORY: "/apps/core"
@ -177,6 +244,7 @@ mix-core:
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
mix-content:
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
extends: .dependabot_gitlab
variables:
DIRECTORY: "/apps/content"

24
.gitlab/ci/.cypress.yml Normal file
View file

@ -0,0 +1,24 @@
cypress:
stage: test
image: cypress/browsers:node14.17.0-chrome91-ff89
variables:
MIX_ENV: e2e
cache:
key: $CI_JOB_NAME
paths:
- _build/
- deps/
- node_modules/
- .npm/
artifacts:
when: always
paths:
- apps/*/cypress/videos/**/*.mp4
- apps/*/cypress/screenshots/**/*.png
expire_in: 1 day
services:
- name: postgres:12
before_script:
- script/cypress/prepare
script:
- npm run test:integration

View file

@ -1,3 +1,3 @@
erlang 23.0.2
elixir 1.12.1
nodejs 14.5.0
nodejs 16.10.0

View file

@ -10,7 +10,6 @@ brew "libyaml"
brew "openssl@1.1"
brew "readline"
brew "unixodbc"
brew "asdf"
brew "curl"
brew "fop"
brew "gnupg"

View file

@ -1,4 +1,6 @@
FROM elixir:1.12.2-alpine AS elixir-builder
FROM elixir:1.12.3-alpine AS elixir-builder
RUN apk add git
RUN mix local.hex --force \
&& mix local.rebar --force
@ -16,6 +18,7 @@ WORKDIR /root/app
# We load these things one by one so that we can load the deps first and
# cache those layers, before we do the app build itself
ADD ./priv ./priv
ADD ./mix.exs ./mix.lock ./
ADD ./config ./config
ADD ./apps/admin/mix.exs ./apps/admin/
@ -27,7 +30,7 @@ RUN mix deps.get
# Leave off here so that we can built assets and compile the elixir app in parallel
FROM node:16.5.0 AS asset-builder
FROM node:16.10.0 AS asset-builder
# Build assets in a node container
ADD ./apps/app/assets/ /root/app/apps/app/assets/

3
apps/admin/cypress.json Normal file
View file

@ -0,0 +1,3 @@
{
"baseUrl": "http://localhost:4002"
}

View file

@ -0,0 +1,5 @@
describe('My First Test', () => {
it('Does not do much!', () => {
expect(true).to.equal(true)
})
})

View file

@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -0,0 +1,36 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add("setupDB", (app, seedSet) => {
cy.request('POST', '/end-to-end/db/setup', {
app,
seed_set: seedSet,
}).as('setupDB')
})
Cypress.Commands.add("teardownDB", () => {
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
})

View file

@ -0,0 +1,29 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
before(() => {
// Make sure we don't have a rogue DB connection checked out
cy.teardownDB()
})
afterEach(() => {
cy.teardownDB()
})

View file

@ -2,6 +2,7 @@ defmodule Kaffy.ResourceQuery do
@moduledoc false
import Ecto.Query
import Ecto.Query.API, only: [field: 2]
def list_resource(conn, resource, params \\ %{}) do
per_page = Map.get(params, "limit", "100") |> String.to_integer()

View file

@ -31,7 +31,7 @@ defmodule Kaffy.MixProject do
defp deps do
[
{:phoenix, "~> 1.4"},
{:phoenix_html, "~> 2.11"},
{:phoenix_html, "~> 2.11 or ~> 3.0"},
{:ecto, "~> 3.0"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
]

View file

@ -1,4 +1,9 @@
defmodule Legendary.Admin.Routes do
@moduledoc """
Routes from the admin app. Use like:
use Legendary.Admin.Routes
"""
defmacro __using__(_opts \\ []) do
quote do
use Kaffy.Routes, scope: "/admin", pipe_through: [:require_admin]

View file

@ -1,4 +1,8 @@
defmodule Legendary.Admin.Telemetry do
@moduledoc """
Collect metrics from the admin app.
"""
use Supervisor
import Telemetry.Metrics

View file

@ -1,4 +1,8 @@
defmodule Legendary.Admin.Kaffy.Config do
@moduledoc """
Pull in the resource list for the admin from the application config.
"""
def create_resources(_conn) do
config = Application.get_env(:admin, Legendary.Admin)

View file

@ -1,14 +1,22 @@
defmodule Legendary.Admin.Kaffy.EditorExtension do
@moduledoc """
Bring in additional CSS and JS for the admin interface e.g. the
markdown editor library.
"""
import Phoenix.HTML.Tag, only: [tag: 2]
def stylesheets(_conn) do
[
{:safe, ~s(<link rel="stylesheet" href="/css/content-editor.css" />)},
{:safe, ~s(<link rel="stylesheet" href="/css/admin.css" />)},
{:safe, ~s(<link rel="stylesheet" href="/css/app.css" />)},
tag(:meta, property: "og:site_name", content: Legendary.I18n.t!("en", "site.title"))
]
end
def javascripts(_conn) do
[
{:safe, ~s(<script src="/js/content-editor.js"></script>)},
{:safe, ~s(<script src="/js/admin.js"></script>)},
{:safe, ~s(<script src="/js/app.js"></script>)},
]
end

View file

@ -1,7 +1,7 @@
defmodule Legendary.Admin.MixProject do
use Mix.Project
@version "2.11.5"
@version "4.3.0"
def project do
[
@ -12,7 +12,7 @@ defmodule Legendary.Admin.MixProject do
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
elixir: "~> 1.7",
elixir: "~> 1.10",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
@ -43,17 +43,17 @@ defmodule Legendary.Admin.MixProject do
defp deps do
[
{:core, in_umbrella: true},
{:ecto_sql, "~> 3.4"},
{:ecto_sql, "~> 3.7"},
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:kaffy, path: "kaffy"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4.0"},
{:phoenix, "~> 1.6.0"},
{:phoenix_ecto, "~> 4.4"},
{:phoenix_html, "~> 3.0.4", override: true},
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:phoenix_live_dashboard, "~> 0.5.0"},
{:postgrex, ">= 0.0.0"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"}

View file

View file

@ -1,5 +1,3 @@
{
"presets": [
"@babel/preset-env"
]
"presets": ["@babel/preset-env"]
}

View file

@ -1 +1,2 @@
node_modules
css/phoenix.css

View file

@ -0,0 +1,5 @@
@import "content-editor-overrides";
.social-media-preview-image.social-media-preview-image {
border-radius: 16px !important;
}

View file

@ -1,11 +1,7 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "@fortawesome/fontawesome-free/css/all.css";
@import "blog";
@import "code";
@ -14,27 +10,28 @@ body {
}
input[type="checkbox"]::after {
content: "";
color: currentColor;
display: inline-flex;
justify-content: center;
width: 100%;
height: 100%;
display: inline-flex;
color: currentColor;
align-items: center;
justify-content: center;
content: "";
}
input[type="checkbox"]:checked::after {
content: "✓";
}
.hidden-options-toggle:checked+.hidden {
.hidden-options-toggle:checked + .hidden {
display: block;
}
.password-revealer {
@apply absolute;
height: 45px;
width: 45px;
right: 0;
bottom: 0;
width: 45px;
height: 45px;
}

View file

@ -57,7 +57,7 @@
.hljs {
@apply mt-6 mb-12 border-l-4 border-indigo-900 font-mono;
--background-color: theme("colors.indigo.100");
--background-color: theme("colors.indigo.100"); /* stylelint-disable-line custom-property-empty-line-before */
--attribute-color: theme("colors.orange.700");
--section-color: theme("colors.teal.700");
--string-color: theme("colors.green.700");

View file

@ -0,0 +1,4 @@
import "../css/admin.css";
import "./admin/content-editor";
import "./admin/preview-image";

View file

@ -0,0 +1,33 @@
import { ready } from "../utils";
import SimpleMDE from "simplemde";
import "simplemde/dist/simplemde.min.css";
import "../../css/content-editor-overrides.css";
const requestPreview = (plainText, previewContainer) => {
let request = new XMLHttpRequest();
const postForm = previewContainer.closest("form");
let formData = new FormData(postForm);
formData.set("post[content]", plainText);
request.addEventListener("load", function (event) {
previewContainer.innerHTML = event.target.responseText;
});
request.open("POST", "/posts/preview", true);
request.send(formData);
};
ready(() => {
document.querySelectorAll("[data-simplemde]").forEach((el) => {
new SimpleMDE({
element: el,
previewRender: (plainText, previewContainer) => {
requestPreview(plainText, previewContainer);
return previewContainer.innerHTML;
},
});
});
});

View file

@ -0,0 +1,140 @@
import { ready } from "../utils";
import { fabric } from "fabric";
fabric.Object.prototype.objectCaching = false;
const textboxDefaults = {
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockSkewingX: true,
lockSkewingY: true,
lockRotation: true,
lockUniScaling: true,
hasControls: false,
selectable: true,
fontFamily:
"system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'",
};
const updateDataURL = (canvas) => {
canvas.renderAll();
const data = canvas.toDataURL({
enableRetinaScaling: true,
});
canvas.dataURLInput.value = data;
};
const setTextboxValue = (canvas, textbox, value) => {
textbox.set({ text: value });
updateDataURL(canvas);
};
const setTextboxFromEvent = (canvas, textbox, { target }) => {
setTextboxValue(canvas, textbox, target.value);
};
const makeLinkedTextbox = (canvas, selector, opts) => {
const box = new fabric.Textbox("", {
...textboxDefaults,
...opts,
});
box.on("input", updateDataURL.bind(box, canvas));
var input = document.querySelector(selector);
canvas.add(box);
setTextboxValue(canvas, box, input.value);
input.addEventListener("input", setTextboxFromEvent.bind(input, canvas, box));
return box;
};
const makeStaticTextbox = (canvas, value, opts) => {
const box = new fabric.Textbox(value, {
...textboxDefaults,
...opts,
});
box.on("input", updateDataURL.bind(box, canvas));
canvas.add(box);
return box;
};
const prepareCanvas = (input, canvasElement) => {
const inputContainer = input.parentElement;
input.setAttribute("type", "hidden");
canvasElement.setAttribute(
"class",
"social-media-preview-image rounded-lg border-2 border-gray-300"
);
canvasElement.setAttribute("width", 800);
canvasElement.setAttribute("height", 418);
inputContainer.appendChild(canvasElement);
const canvas = new fabric.Canvas(canvasElement);
canvas.dataURLInput = input;
return canvas;
};
ready(() => {
const input = document.querySelector(
"[name='post[social_media_preview_image]']"
);
const canvasElement = document.createElement("canvas");
const canvas = prepareCanvas(input, canvasElement);
fabric.Image.fromURL(
"/images/social-media-preview-background.png",
function (oImg) {
oImg.selectable = false;
canvas.add(oImg);
const title = makeLinkedTextbox(canvas, "[name='post[title]']", {
left: 80,
top: 80,
width: 640,
fontWeight: "bold",
fontSize: 36,
});
makeLinkedTextbox(canvas, "[name='post[excerpt]']", {
left: 80,
width: 560,
top: title.aCoords.bl.y + 20,
fill: "#4B5563",
fontSize: 18,
});
var name = document
.querySelector("[property='og:site_name']")
.getAttribute("content");
makeStaticTextbox(canvas, name, {
left: 80,
width: 560,
top: 48,
fill: "#F87171",
fontSize: 18,
fontWeight: "bold",
});
var rect = new fabric.Rect({
left: 0,
top: 0,
fill: "#F87171",
width: 14,
height: 418,
selectable: false,
});
canvas.add(rect);
updateDataURL(canvas);
}
);
});

View file

@ -1,7 +1,7 @@
// We need to import the CSS so that webpack will load it.
// The MiniCssExtractPlugin is used to separate it out into
// its own CSS file.
import "../css/app.css"
import "../css/app.css";
// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
@ -12,48 +12,46 @@ import "../css/app.css"
// import {Socket} from "phoenix"
// import socket from "./socket"
//
import "phoenix_html"
import "alpinejs"
import "./live"
import { ready } from "./utils"
import "phoenix_html";
import "alpinejs";
import "./live";
import { ready } from "./utils";
function togglePasswordFieldVisibility()
{
const passwordFields = document.querySelectorAll('[name="user[password]"]')
var bloop;
function togglePasswordFieldVisibility() {
const passwordFields = document.querySelectorAll('[name="user[password]"]');
passwordFields.forEach((el) => {
if (el.type == 'password')
{
el.type = 'text'
if (el.type == "password") {
el.type = "text";
} else {
el.type = "password";
}
else
{
el.type = 'password'
}
})
});
}
const toggleSidebar = (event) => {
document.querySelectorAll('.sidebar').forEach((el) => {
el.classList.toggle('visible')
})
}
document.querySelectorAll(".sidebar").forEach((el) => {
el.classList.toggle("visible");
});
};
ready(() => {
(document.getElementById('nav-toggle') ||{}).onclick = function(){
(document.getElementById("nav-toggle") || {}).onclick = function () {
document.getElementById("nav-content").classList.toggle("hidden");
}
};
document.querySelectorAll('.js-passwordRevealer').forEach((el) => {
el.addEventListener('click', togglePasswordFieldVisibility)
})
document.querySelectorAll(".js-passwordRevealer").forEach((el) => {
el.addEventListener("click", togglePasswordFieldVisibility);
});
document.querySelectorAll('.js-SidebarOpener').forEach((el) => {
el.addEventListener('click', toggleSidebar)
})
document.querySelectorAll(".js-SidebarOpener").forEach((el) => {
el.addEventListener("click", toggleSidebar);
});
document.querySelectorAll('.js-flash-closer').forEach((el) => {
el.addEventListener('click', () => {
el.closest('.js-flash').remove()
})
})
})
document.querySelectorAll(".js-flash-closer").forEach((el) => {
el.addEventListener("click", () => {
el.closest(".js-flash").remove();
});
});
});

View file

@ -1,34 +0,0 @@
import { ready } from "./utils"
import SimpleMDE from "simplemde"
import "simplemde/dist/simplemde.min.css"
import "../css/content-editor-overrides.css"
const requestPreview = (plainText, previewContainer) => {
let request = new XMLHttpRequest()
const postForm = previewContainer.closest('form')
let formData = new FormData(postForm)
formData.set('post[content]', plainText)
request.addEventListener('load', function(event) {
previewContainer.innerHTML = event.target.responseText
})
request.open('POST', '/pages/posts/preview', true)
request.send(formData)
}
ready(() => {
document.querySelectorAll('[data-simplemde]').forEach(el => {
new SimpleMDE({
element: el,
previewRender: (plainText, previewContainer) => {
requestPreview(plainText, previewContainer)
return previewContainer.innerHTML
},
})
})
})

View file

@ -1,18 +1,25 @@
import {Socket} from "phoenix"
import LiveSocket from "phoenix_live_view"
import topbar from "topbar"
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import topbar from "topbar";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
});
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#3B82F6"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
topbar.config({
barColors: { 0: "#3B82F6" },
shadowColor: "rgba(0, 0, 0, .3)",
});
window.addEventListener("phx:page-loading-start", (info) => topbar.show());
window.addEventListener("phx:page-loading-stop", (info) => topbar.hide());
// Connect if there are any LiveViews on the page
liveSocket.connect()
liveSocket.connect();
// Expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
@ -20,4 +27,4 @@ liveSocket.connect()
// The latency simulator is enabled for the duration of the browser session.
// Call disableLatencySim() to disable:
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
window.liveSocket = liveSocket;

View file

@ -6,9 +6,9 @@
//
// Pass the token on params as below. Or remove it
// from the params if you are not using authentication.
import {Socket} from "phoenix"
import { Socket } from "phoenix";
let socket = new Socket("/socket", {params: {token: window.userToken}})
let socket = new Socket("/socket", { params: { token: window.userToken } });
// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
@ -52,12 +52,17 @@ let socket = new Socket("/socket", {params: {token: window.userToken}})
// end
//
// Finally, connect to the socket:
socket.connect()
socket.connect();
// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("topic:subtopic", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
let channel = socket.channel("topic:subtopic", {});
channel
.join()
.receive("ok", (resp) => {
console.log("Joined successfully", resp);
})
.receive("error", (resp) => {
console.log("Unable to join", resp);
});
export default socket
export default socket;

View file

@ -1,10 +1,9 @@
const ready = (fn) => {
if (document.readyState != 'loading') {
fn()
if (document.readyState != "loading") {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn)
document.addEventListener("DOMContentLoaded", fn);
}
}
};
export { ready }
export { ready };

File diff suppressed because it is too large Load diff

View file

@ -5,20 +5,24 @@
"scripts": {
"deploy": "webpack --mode production",
"watch": "webpack --mode development --watch",
"profile": "webpack --mode development --plugin webpack/lib/debug/ProfilingPlugin"
"profile": "webpack --mode development --plugin webpack/lib/debug/ProfilingPlugin",
"preinstall": "npx npm-force-resolutions"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"alpinejs": "^2.8.1",
"autoprefixer": "^9.8.6",
"csswring": "^7.0.0",
"fabric": "^4.6.0",
"glob": "^7.1.6",
"npm-force-resolutions": "^0.0.10",
"phoenix": "file:/../../../deps/phoenix",
"phoenix_html": "file:/../../../deps/phoenix_html",
"phoenix_live_view": "file:../../../deps/phoenix_live_view",
"postcss-clean": "^1.2.2",
"postcss-color-function": "^4.1.0",
"simplemde": "^1.11.2",
"tailwindcss": "^1.7.3",
"squeak": "^1.3.0",
"tailwindcss": "^2.2.6",
"topbar": "^1.0.1"
},
"devDependencies": {
@ -29,25 +33,28 @@
"css-loader": "^3.4.2",
"css-minimizer-webpack-plugin": "^3.0.2",
"file-loader": "^6.0.0",
"image-webpack-loader": "^6.0.0",
"image-webpack-loader": "^7.0.1",
"less": "^3.11.3",
"less-loader": "^6.2.0",
"mini-css-extract-plugin": "^1.6.2",
"postcss-css-variables": "^0.17.0",
"postcss-import": "^12.0.1",
"postcss-loader": "^6.1.0",
"prettier": "2.3.2",
"sass": "^1.35.1",
"sass-loader": "^8.0.2",
"style-loader": "^1.2.1",
"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",
"stylelint": "^13.8.0",
"stylelint-config-standard": "^20.0.0",
"stylelint-order": "^4.1.0",
"terser-webpack-plugin": "^2.3.2",
"terser-webpack-plugin": "^5.1.4",
"webpack": "^5.1.0",
"webpack-cli": "^4.7.2",
"yargs-parser": "^20.2.9"
},
"resolutions": {
"graceful-fs": "4.2.3"
"graceful-fs": "4.2.3",
"meow": "9.0.0",
"tempfile": "4.0.0"
}
}

View file

@ -1,13 +1,11 @@
module.exports = {
plugins: [
require('postcss-import')({
plugins: [
require('stylelint')(),
]
require("postcss-import")({
plugins: [require("stylelint")()],
}),
require('tailwindcss'),
require('autoprefixer'),
require('csswring')(),
require('postcss-color-function')()
require("tailwindcss"),
require("autoprefixer"),
require("postcss-clean")(),
require("postcss-color-function")(),
],
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -1,29 +1,163 @@
const yargsParser = require('yargs-parser');
const yargsParser = require("yargs-parser");
const cliArgs = yargsParser(process.argv);
const mode = process.env.NODE_ENV || cliArgs.mode || 'development';
const mode = process.env.NODE_ENV || cliArgs.mode || "development";
module.exports = {
future: {
removeDeprecatedGapUtilities: true,
purgeLayersByDefault: true,
},
purge: {
enabled: mode == 'production',
layers: ['base', 'components', 'utilities'],
enabled: mode == "production",
layers: ["base", "components", "utilities"],
content: [
'../../../**/views/*.ex',
'../../../**/*.html.eex',
'../../../**/*.html.leex',
'../../../**/*.html.heex',
'./js/**/*.js'
]
"../../../**/views/*.ex",
"../../../**/*.html.eex",
"../../../**/*.html.leex",
"../../../**/*.html.heex",
"./js/**/*.js",
],
},
theme: {
extend: {},
colors: {
transparent: "transparent",
current: "currentColor",
black: "#000",
white: "#fff",
gray: {
100: "#f7fafc",
200: "#edf2f7",
300: "#e2e8f0",
400: "#cbd5e0",
500: "#a0aec0",
600: "#718096",
700: "#4a5568",
800: "#2d3748",
900: "#1a202c",
},
red: {
100: "#fff5f5",
200: "#fed7d7",
300: "#feb2b2",
400: "#fc8181",
500: "#f56565",
600: "#e53e3e",
700: "#c53030",
800: "#9b2c2c",
900: "#742a2a",
},
orange: {
100: "#fffaf0",
200: "#feebc8",
300: "#fbd38d",
400: "#f6ad55",
500: "#ed8936",
600: "#dd6b20",
700: "#c05621",
800: "#9c4221",
900: "#7b341e",
},
yellow: {
100: "#fffff0",
200: "#fefcbf",
300: "#faf089",
400: "#f6e05e",
500: "#ecc94b",
600: "#d69e2e",
700: "#b7791f",
800: "#975a16",
900: "#744210",
},
green: {
100: "#f0fff4",
200: "#c6f6d5",
300: "#9ae6b4",
400: "#68d391",
500: "#48bb78",
600: "#38a169",
700: "#2f855a",
800: "#276749",
900: "#22543d",
},
teal: {
100: "#e6fffa",
200: "#b2f5ea",
300: "#81e6d9",
400: "#4fd1c5",
500: "#38b2ac",
600: "#319795",
700: "#2c7a7b",
800: "#285e61",
900: "#234e52",
},
blue: {
100: "#ebf8ff",
200: "#bee3f8",
300: "#90cdf4",
400: "#63b3ed",
500: "#4299e1",
600: "#3182ce",
700: "#2b6cb0",
800: "#2c5282",
900: "#2a4365",
},
indigo: {
100: "#ebf4ff",
200: "#c3dafe",
300: "#a3bffa",
400: "#7f9cf5",
500: "#667eea",
600: "#5a67d8",
700: "#4c51bf",
800: "#434190",
900: "#3c366b",
},
purple: {
100: "#faf5ff",
200: "#e9d8fd",
300: "#d6bcfa",
400: "#b794f4",
500: "#9f7aea",
600: "#805ad5",
700: "#6b46c1",
800: "#553c9a",
900: "#44337a",
},
pink: {
100: "#fff5f7",
200: "#fed7e2",
300: "#fbb6ce",
400: "#f687b3",
500: "#ed64a6",
600: "#d53f8c",
700: "#b83280",
800: "#97266d",
900: "#702459",
},
},
fontSize: {
xs: "0.75rem",
sm: "0.875rem",
base: "1rem",
lg: "1.125rem",
xl: "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem",
"4xl": "2.25rem",
"5xl": "3rem",
"6xl": "4rem",
},
extend: {
boxShadow: {
xs: "0 0 0 1px rgba(0, 0, 0, 0.05)",
outline: "0 0 0 3px rgba(66, 153, 225, 0.5)",
},
},
},
variants: {
backgroundColor: ['responsive', 'hover', 'focus', 'checked'],
backgroundColor: ["responsive", "hover", "focus", "checked"],
extend: {
fontWeight: ["hover", "focus"],
},
},
plugins: [],
}
};

View file

@ -1,31 +1,31 @@
const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const path = require("path");
const glob = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const nodeModulesPath = path.resolve(__dirname, 'node_modules')
const nodeModulesPath = path.resolve(__dirname, "node_modules");
module.exports = (env, options) => {
const devMode = options.mode !== 'production';
const devMode = options.mode !== "production";
return {
optimization: {
minimizer: [
new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
new TerserPlugin({ parallel: true }),
new CssMinimizerPlugin(),
]
],
},
mode: options.mode,
devtool: devMode ? 'source-map' : undefined,
devtool: devMode ? "source-map" : undefined,
entry: {
'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']),
'content-editor': ['./js/content-editor.js'],
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
admin: ["./js/admin.js"],
},
output: {
filename: 'js/[name].js',
path: path.resolve(__dirname, '../priv/static/')
filename: "js/[name].js",
path: path.resolve(__dirname, "../priv/static/"),
},
module: {
rules: [
@ -33,9 +33,9 @@ module.exports = (env, options) => {
{
test: /\.(jpg|jpeg|gif|png)$/,
use: [
'file-loader',
"file-loader",
{
loader: 'image-webpack-loader',
loader: "image-webpack-loader",
options: {
disable: devMode,
},
@ -44,61 +44,108 @@ module.exports = (env, options) => {
},
{
test: /\.(woff2?|ttf|eot|svg)(\?[a-z0-9\=\.]+)?$/,
loader: 'file-loader',
loader: "file-loader",
options: {
publicPath: '/fonts',
publicPath: "/fonts",
outputPath: (url, resourcePath, context) => {
return `/fonts/${url}`;
},
}
},
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
loader: "babel-loader",
},
},
{
test: /\.css$/,
use: [
{loader: MiniCssExtractPlugin.loader},
{loader: 'css-loader', options: {sourceMap: true}},
{loader: 'postcss-loader', options: {sourceMap: true}},
{ loader: MiniCssExtractPlugin.loader },
{ loader: "css-loader", options: { sourceMap: true } },
{ loader: "postcss-loader", options: { sourceMap: true } },
],
},
]
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].css',
chunkFilename: '[id].css',
filename: "css/[name].css",
chunkFilename: "[id].css",
}),
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, "static"),
to: path.resolve(__dirname, "../priv/static"),
},
],
}),
new CopyWebpackPlugin({patterns: [
{
from: path.resolve(__dirname, 'static'),
to: path.resolve(__dirname, '../priv/static'),
},
]}),
],
resolve: {
alias: {
"../webfonts/fa-brands-400.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"),
"../webfonts/fa-brands-400.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"),
"../webfonts/fa-brands-400.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"),
"../webfonts/fa-brands-400.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf"),
"../webfonts/fa-brands-400.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.svg"),
"../webfonts/fa-regular-400.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.eot"),
"../webfonts/fa-regular-400.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"),
"../webfonts/fa-regular-400.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff"),
"../webfonts/fa-regular-400.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf"),
"../webfonts/fa-regular-400.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.svg"),
"../webfonts/fa-solid-900.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.eot"),
"../webfonts/fa-solid-900.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"),
"../webfonts/fa-solid-900.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff"),
"../webfonts/fa-solid-900.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf"),
"../webfonts/fa-solid-900.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.svg"),
}
"../webfonts/fa-brands-400.eot": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"
),
"../webfonts/fa-brands-400.woff2": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
),
"../webfonts/fa-brands-400.woff": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"
),
"../webfonts/fa-brands-400.ttf": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf"
),
"../webfonts/fa-brands-400.svg": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.svg"
),
"../webfonts/fa-regular-400.eot": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.eot"
),
"../webfonts/fa-regular-400.woff2": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"
),
"../webfonts/fa-regular-400.woff": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff"
),
"../webfonts/fa-regular-400.ttf": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf"
),
"../webfonts/fa-regular-400.svg": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.svg"
),
"../webfonts/fa-solid-900.eot": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.eot"
),
"../webfonts/fa-solid-900.woff2": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"
),
"../webfonts/fa-solid-900.woff": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff"
),
"../webfonts/fa-solid-900.ttf": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf"
),
"../webfonts/fa-solid-900.svg": path.resolve(
__dirname,
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.svg"
),
},
},
}
};
};

3
apps/app/cypress.json Normal file
View file

@ -0,0 +1,3 @@
{
"baseUrl": "http://localhost:4002"
}

View file

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

@ -0,0 +1,9 @@
describe('Blog Page', () => {
it('shows posts', () => {
cy.setupDB("app", "blog")
cy.visit('/blog')
cy.get('article').should('exist')
})
})

View file

@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -0,0 +1,36 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add("setupDB", (app, seedSet) => {
cy.request('POST', '/end-to-end/db/setup', {
app,
seed_set: seedSet,
}).as('setupDB')
})
Cypress.Commands.add("teardownDB", () => {
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
})

View file

@ -0,0 +1,29 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
before(() => {
// Make sure we don't have a rogue DB connection checked out
cy.teardownDB()
})
afterEach(() => {
cy.teardownDB()
})

View file

@ -31,7 +31,7 @@ defmodule AppWeb.Endpoint do
at: "/",
from: :app,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
only: ~w(css fonts images js favicon.ico robots.txt public_uploads)
plug Plug.Static,
at: "/kaffy",

View file

@ -4,19 +4,16 @@ defmodule AppWeb.LiveHelpers do
"""
import Phoenix.LiveView
alias Legendary.Auth.User
alias Pow.Store.CredentialsCache
alias AppWeb.Pow.Routes
def assign_defaults(socket, session) do
assign_new(socket, :current_user, fn -> get_user(socket, session) end)
end
def require_auth(socket) do
if socket.assigns.current_user do
socket
else
redirect(socket, to: Routes.after_sign_out_path(%Plug.Conn{}))
case socket.assigns do
%{current_user: user} when not is_nil(user) ->
socket
_ ->
redirect(socket, to: "/")
end
end

View file

@ -51,6 +51,10 @@ defmodule AppWeb.Router do
forward "/sent_emails", Bamboo.SentEmailViewerPlug
end
if Mix.env() in [:e2e, :test] do
forward("/end-to-end", Legendary.CoreWeb.Plug.TestEndToEnd, otp_app: :app)
end
scope "/" do
pipe_through :browser
@ -60,5 +64,6 @@ defmodule AppWeb.Router do
use Legendary.Core.Routes
use Legendary.Admin.Routes
use Legendary.ObjectStorageWeb.Routes
use Legendary.Content.Routes
end

View file

@ -1,4 +1,8 @@
defmodule AppWeb.Telemetry do
@moduledoc """
Collect metrics on your app.
"""
use Supervisor
import Telemetry.Metrics

View file

@ -0,0 +1,26 @@
<%= live_title_tag title(@conn, assigns) %>
<%= tag :meta, itemprop: "name", content: title(@conn, assigns) %>
<%= tag :meta, itemprop: "description", content: excerpt(@conn, assigns) %>
<%= tag :meta, name: "description", content: excerpt(@conn, assigns) %>
<!-- Twitter Card -->
<%= tag :meta, name: "twitter:card", content: "summary_large_image" %>
<%= tag :meta, name: "twitter:title", content: title(@conn, assigns) %>
<%= tag :meta, name: "twitter:description", content: excerpt(@conn, assigns) %>
<!-- <meta name="twitter:site" content="@site_handle"> -->
<!-- <meta name="twitter:creator" content="@author_handle"> -->
<!-- Facebook / Open Graph -->
<%= tag :meta, property: "og:title", content: title(@conn, assigns) %>
<%= tag :meta, property: "og:type", content: "article" %>
<%= tag :meta, property: "og:description", content: excerpt(@conn, assigns) %>
<%= tag :meta, property: "og:site_name", content: Legendary.I18n.t!("en", "site.title") %>
<%= modified_tag(@conn, assigns) %>
<%= published_tag(@conn, assigns) %>
<!-- <meta property="article:section" content="Article Section" /> -->
<!-- <meta property="article:tag" content="Article Tag" /> -->
<!-- <meta property="fb:admins" content="Facebook numberic ID" /> -->
<!-- Preview Images -->
<%= preview_image_tags(@conn, assigns) %>

View file

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag title(@view_module, @view_template, assigns) %>
<%= render "_social.html", assigns %>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>

View file

@ -8,10 +8,12 @@ defmodule AppWeb.ErrorHelpers do
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
def error_tag(form, field, opts \\ []) do
{extra_classes, _rest_opts} = Keyword.pop(opts, :class, "")
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
class: "invalid-feedback #{extra_classes}",
phx_feedback_for: input_id(form, field)
)
end)

View file

@ -1,15 +1,73 @@
defmodule AppWeb.LayoutView do
use AppWeb, :view
alias Legendary.ContentWeb.Uploaders.SocialMediaPreview
def title(conn, assigns), do: title(view_module(conn), view_template(conn), assigns)
def title(view, template, %{post: post}), do: "#{post.title} | #{title(view, template, nil)}"
def title(_, _, _) do
Legendary.I18n.t!("en", "site.title")
end
end
def excerpt(_, _, _) do
Legendary.I18n.t!("en", "site.excerpt")
end
def excerpt(conn, assigns), do: excerpt(view_module(conn), view_template(conn), assigns)
def feed_tag(_, _, _, _) do
nil
end
def excerpt(_, _, %{post: post}) do
post.excerpt
end
def excerpt(_, _, _) do
Legendary.I18n.t!("en", "site.excerpt")
end
def feed_tag(conn, assigns), do: feed_tag(conn, view_module(conn), view_template(conn), assigns)
def feed_tag(_, _, _, _) do
nil
end
def modified_tag(conn, assigns), do: modified_tag(view_module(conn), view_template(conn), assigns)
def modified_tag(_, _, %{post: post}) do
content =
post.modified_gmt
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
tag :meta, property: "article:modified_time", content: content
end
def modified_tag(_, _, _) do
nil
end
def published_tag(conn, assigns), do: modified_tag(view_module(conn), view_template(conn), assigns)
def published_tag(_, _, %{post: post}) do
content =
post.date_gmt
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
tag :meta, property: "article:published_time", content: content
end
def published_tag(_, _, _) do
nil
end
def preview_image_tags(_conn, %{post: post}) do
url = SocialMediaPreview.url({"original.png", post}, :original)
[
tag(:meta, itemprop: "image", content: url),
tag(:meta, name: "twitter:image:src", content: url),
tag(:meta, property: "og:image", content: url)
]
end
def preview_image_tags(_, _) do
nil
end
end

View file

@ -9,7 +9,7 @@ defmodule App.MixProject do
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
elixir: "~> 1.7",
elixir: "~> 1.10",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
@ -42,19 +42,20 @@ defmodule App.MixProject do
{:admin, in_umbrella: true},
{:content, in_umbrella: true},
{:core, in_umbrella: true},
{:ecto_sql, "~> 3.4"},
{:object_storage, in_umbrella: true},
{:ecto_sql, "~> 3.7"},
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:floki, ">= 0.30.0"},
{:oban, "~> 2.1"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4.0"},
{:phoenix_live_view, "~> 0.15.7", override: true},
{:oban, "~> 2.9"},
{:phoenix, "~> 1.6.0"},
{:phoenix_ecto, "~> 4.4"},
{:phoenix_html, "~> 3.0.4", override: true},
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:phoenix_live_dashboard, "~> 0.5.0"},
{:phoenix_live_view, "~> 0.16.0", override: true},
{:postgrex, ">= 0.0.0"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"}

View file

@ -0,0 +1,29 @@
defmodule AppWeb.ErrorHelpersTest do
use AppWeb.ConnCase
import Phoenix.HTML, only: [safe_to_string: 1]
import Phoenix.HTML.Form, only: [form_for: 3]
import AppWeb.ErrorHelpers
def form do
:example
|> form_for(
"/example",
as: :test_params,
errors: [error_field: {"is an error", []}]
)
end
describe "error_tag/2" do
test "generates a span with an invalid-feedback class" do
[safe] = error_tag(form(), :error_field)
assert safe_to_string(safe) =~ "invalid-feedback"
end
test "error_tag/3" do
[safe] = error_tag(form(), :error_field, class: "test-class")
assert safe_to_string(safe) =~ "test-class"
end
end
end

View file

@ -0,0 +1,46 @@
defmodule AppWeb.LiveHelpersText do
use AppWeb.ConnCase
import Mock
import AppWeb.LiveHelpers
describe "assign_defaults/2" do
test "sets current_user" do
{store, _store_config} = Pow.Plug.Base.store(Application.get_env(:core, :pow))
socket = %Phoenix.LiveView.Socket{endpoint: AppWeb.Endpoint}
with_mock Pow.Plug, [verify_token: fn (_, _, _, _) -> {:ok, "h3110"} end] do
with_mock store, [get: fn (_config, _token) -> {%{id: 1234}, nil} end] do
new_socket = assign_defaults(socket, %{"core_auth" => "h3ll0"})
assert %{assigns: %{current_user: %{id: 1234}}} = new_socket
end
end
end
end
describe "require_auth/1" do
test "with user" do
user = %{id: 4567}
socket =
%Phoenix.LiveView.Socket{assigns: %{current_user: user}}
|> require_auth()
assert !socket.redirected
end
test "without user" do
socket = %Phoenix.LiveView.Socket{} |> require_auth()
assert socket.redirected
end
test "without nil user" do
socket =
%Phoenix.LiveView.Socket{assigns: %{current_user: nil}}
|> require_auth()
assert socket.redirected
end
end
end

View file

@ -2,6 +2,17 @@ defmodule App.LayoutViewTest do
use AppWeb.ConnCase, async: true
import AppWeb.LayoutView
import Phoenix.HTML, only: [safe_to_string: 1]
alias Legendary.Content.Post
@post %Post{
name: "test-slug",
title: "Test Post",
excerpt: "This is a test post.",
modified_gmt: ~N[2021-09-17T00:00:00],
date_gmt: ~N[2021-09-15T00:00:00]
}
describe "title/3" do
def default_title do
@ -11,6 +22,10 @@ defmodule App.LayoutViewTest do
test "for nil" do
assert title(nil, nil, nil) =~ default_title()
end
test "for post" do
assert title(nil, nil, %{post: @post}) =~ "Test Post | #{default_title()}"
end
end
describe "excerpt/3" do
@ -21,6 +36,10 @@ defmodule App.LayoutViewTest do
test "for nil" do
assert excerpt(nil, nil, nil) =~ default_excerpt()
end
test "for post" do
assert excerpt(nil, nil, %{post: @post}) =~ "This is a test post."
end
end
describe "feed_tag/4" do
@ -28,4 +47,39 @@ defmodule App.LayoutViewTest do
assert feed_tag(nil, nil, nil, nil) == nil
end
end
describe "modified_tag/3" do
test "for a post" do
assert safe_to_string(modified_tag(nil, nil, %{post: @post})) =~ "2021-09-17"
end
test "without a post" do
assert modified_tag(nil, nil, nil) == nil
end
end
describe "published_tag/3" do
test "for a post" do
assert safe_to_string(published_tag(nil, nil, %{post: @post})) =~ "2021-09-15"
end
test "without a post" do
assert published_tag(nil, nil, nil) == nil
end
end
describe "preview_image_tags/2" do
test "for a post" do
markup =
preview_image_tags(nil, %{post: @post})
|> Enum.map(&safe_to_string/1)
|> Enum.join("")
assert markup =~ "/public_uploads/content/posts/preview_images/test-slug/original.png"
end
test "without a post" do
assert preview_image_tags(nil, nil) == nil
end
end
end

View file

@ -0,0 +1,11 @@
alias Legendary.Content.Post
alias Legendary.Content.Repo
%Post{
title: "Public post",
name: "public-post",
status: "publish",
type: "post",
date: ~N[2020-01-01T00:00:00],
}
|> Repo.insert!()

View file

@ -0,0 +1,3 @@
{
"baseUrl": "http://localhost:4002"
}

View file

@ -0,0 +1,5 @@
describe('My First Test', () => {
it('Does not do much!', () => {
expect(true).to.equal(true)
})
})

View file

@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -0,0 +1,36 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add("setupDB", (app, seedSet) => {
cy.request('POST', '/end-to-end/db/setup', {
app,
seed_set: seedSet,
}).as('setupDB')
})
Cypress.Commands.add("teardownDB", () => {
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
})

View file

@ -0,0 +1,29 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
before(() => {
// Make sure we don't have a rogue DB connection checked out
cy.teardownDB()
})
afterEach(() => {
cy.teardownDB()
})

View file

@ -7,12 +7,11 @@ defmodule Legendary.Content.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec
# Define workers and child supervisors to be supervised
children = [
# Start the Ecto repository
supervisor(Legendary.Content.Repo, []),
Legendary.Content.Repo,
# Start the endpoint when the application starts
# Start your own worker by calling: Legendary.Content.Worker.start_link(arg1, arg2, arg3)
# worker(Legendary.Content.Worker, [arg1, arg2, arg3]),

View file

@ -1,4 +1,8 @@
defmodule Legendary.Content.CommentAdmin do
@moduledoc """
Custom admin logic for blog post comments.
"""
def index(_) do
[
id: nil,

View file

@ -1,4 +1,8 @@
defmodule Legendary.Content.MarkupField do
@moduledoc """
Custom field type definition for markdown fields. Currently uses simplemde
to provide a markdown editing GUI.
"""
use Ecto.Type
def type, do: :string

View file

@ -116,15 +116,17 @@ defmodule Legendary.Content.Post do
def maybe_put_guid(changeset) do
import Legendary.Content.Router.Helpers, only: [url: 1, posts_url: 3]
slug = changeset |> get_field(:name)
guid = changeset |> get_field(:guid)
case slug do
nil -> changeset
_ ->
case guid do
nil ->
base = url(Legendary.CoreWeb.Endpoint)
slug = changeset |> get_field(:name)
changeset
|> put_default(:guid, posts_url(URI.merge(base, "/pages"), :show, slug))
_ ->
changeset
end
end
end

View file

@ -1,4 +1,10 @@
defmodule Legendary.Content.PostAdmin do
@moduledoc """
Custom admin logic for content posts and pages.
"""
alias Legendary.Content.{Post, Posts.PreviewImages}
import Ecto.Query, only: [from: 2]
def singular_name(_) do
@ -10,11 +16,15 @@ defmodule Legendary.Content.PostAdmin do
end
def create_changeset(schema, attrs) do
Legendary.Content.Post.changeset(schema, attrs)
schema
|> Post.changeset(attrs)
|> PreviewImages.handle_preview_image_upload(attrs)
end
def update_changeset(schema, attrs) do
Legendary.Content.Post.changeset(schema, attrs)
schema
|> Post.changeset(attrs)
|> PreviewImages.handle_preview_image_upload(attrs)
end
def index(_) do
@ -54,6 +64,7 @@ defmodule Legendary.Content.PostAdmin do
comment_status: %{choices: [{"open", :open}, {"closed", :closed}]},
ping_status: %{choices: [{"open", :open}, {"closed", :closed}]},
menu_order: nil,
social_media_preview_image: %{type: :hidden},
]
end
end

View file

@ -0,0 +1,30 @@
defmodule Legendary.Content.Posts.PreviewImages do
@moduledoc """
Handles storing social media preview images which are submitted as data uris
in the social_media_preview_image field.
"""
alias Ecto.Changeset
alias Legendary.ContentWeb.Uploaders.SocialMediaPreview
alias Legendary.CoreWeb.Base64Uploads
def handle_preview_image_upload(changeset, attrs) do
upload =
case attrs do
%{"social_media_preview_image" => data} when is_binary(data) ->
Base64Uploads.data_uri_to_upload(data)
_ -> nil
end
case upload do
nil ->
changeset
%Plug.Upload{} ->
name = Changeset.get_field(changeset, :name)
{:ok, _filename} = SocialMediaPreview.store({upload, %{name: name}})
changeset
:error ->
changeset
|> Changeset.add_error(:social_media_preview_image, "is malformed")
end
end
end

View file

@ -21,6 +21,7 @@ defmodule Legendary.Content.Sitemaps do
generate()
end
@spec generate :: :ok
def generate do
create do
add "", priority: 0.5, changefreq: "hourly", expires: nil

View file

@ -4,7 +4,7 @@ defmodule Legendary.Content.TermRelationship do
"""
use Ecto.Schema
import Ecto.Changeset
alias Legendary.Content.{Post}
alias Legendary.Content.Post
@primary_key {:object_id, :integer, []}
@primary_key {:term_taxonomy_id, :integer, []}

View file

@ -34,8 +34,6 @@ defmodule Legendary.Content do
namespace: Legendary.Content,
pattern: "**/*"
use PhoenixHtmlSanitizer, :basic_html
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]

View file

@ -67,9 +67,7 @@ defmodule Legendary.Content.PostsController do
conn |> show_one(post, page_string)
end
end
def show(conn, %{"id" => id, "page" => page_string}) when is_list(id) do
show(conn, %{"id" => Enum.join(id, "/"), "page" => page_string})
end
def show(conn, %{"id" => id} = params) when is_list(id), do: show(conn, Map.merge(params, %{"id" => Enum.join(id, "/")}))
def show(conn, %{"id" => id}), do: show(conn, %{"id" => id, "page" => "1"})
defp try_static_post(conn, id) do
@ -87,11 +85,12 @@ defmodule Legendary.Content.PostsController do
end
# The static page we're looking for is missing, so this is just a 404
# credo:disable-for-next-line
raise Phoenix.Router.NoRouteError.exception(conn: conn, router: router)
_ ->
# We aren't missing the static page, we're missing a partial. This is probably
# a developer error, so bubble it up
raise e
reraise e, System.stacktrace
end
end
end
@ -113,10 +112,20 @@ defmodule Legendary.Content.PostsController do
{:ok, decoded} = post.content |> Base.decode64
conn
|> put_resp_content_type(post.mime_type, "binary")
|> put_resp_content_type(post.mime_type, charset(post.mime_type))
|> send_resp(conn.status || 200, decoded)
_ ->
render(conn, template, post: post, page: page, thumbs: thumbs)
end
end
defp charset(mime_type) do
do_charset(String.split(mime_type, "/"))
end
defp do_charset(["application", _]), do: "binary"
defp do_charset(["video", _]), do: "binary"
defp do_charset(["audio", _]), do: "binary"
defp do_charset(["image", _]), do: "binary"
defp do_charset(_), do: "utf-8"
end

View file

@ -1,4 +1,8 @@
defmodule Legendary.Content.Routes do
@moduledoc """
Routes for the content engine, including blog posts, feeds, and pages.
"""
defmacro __using__(_opts \\ []) do
quote do
pipeline :feed do

View file

@ -1,12 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><%= title(@view_module, @view_template, assigns) %></title>
<description><%= excerpt(@view_module, @view_template, assigns) %></description>
<title><%= title(@conn, assigns) %></title>
<description><%= excerpt(@conn, assigns) %></description>
<link><%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %></link>
<atom:link href="<%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %><%= @feed_url %>" rel="self" type="application/rss+xml" />
<%= for post <- @posts do %>
<%= if unauthenticated_post?(post) do %>
<item>
<title><%= post.title |> HtmlSanitizeEx.strip_tags() %></title>
<description>
@ -19,6 +20,7 @@
%></pubDate>
<guid isPermaLink="true"><%= post.guid %></guid>
</item>
<% end %>
<% end %>
</channel>
</rss>

View file

@ -4,9 +4,9 @@
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title><%= title(@view_module, @view_template, assigns) %></title>
<title><%= title(@conn, assigns) %></title>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<%= feed_tag(@conn, @view_module, @view_template, assigns) %>
<%= feed_tag(@conn, assigns) %>
<script phx-track-static defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body class="text-gray-800 antialiased">

View file

@ -13,7 +13,7 @@
</div>
</div>
<div>
<%= sanitize comment.content |> auto_paragraph_tags |> elem(1) |> IO.iodata_to_binary() %>
<%= HtmlSanitizeEx.basic_html(comment.content |> auto_paragraph_tags |> elem(1) |> IO.iodata_to_binary()) %>
</div>
</div>
<% end) %>

View file

@ -0,0 +1,18 @@
defmodule Legendary.ContentWeb.Uploaders.SocialMediaPreview do
@moduledoc """
Uploader definition for social media preview images.
"""
use Waffle.Definition
@versions [:original]
# Override the persisted filenames:
def filename(version, _) do
Atom.to_string(version)
end
# Override the storage directory:
def storage_dir(_version, {_file, %{name: name}}) do
"public_uploads/content/posts/preview_images/#{name}"
end
end

View file

@ -8,10 +8,12 @@ defmodule Legendary.Content.ErrorHelpers do
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
def error_tag(form, field, opts \\ []) do
{extra_classes, _rest_opts} = Keyword.pop(opts, :class, "")
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
class: "invalid-feedback #{extra_classes}",
phx_feedback_for: input_id(form, field)
)
end)

View file

@ -1,71 +1,10 @@
defmodule Legendary.Content.FeedsView do
use Legendary.Content, :view
use Phoenix.HTML
alias Phoenix.HTML
alias Phoenix.HTML.Tag
import Legendary.Content.LayoutView, only: [title: 3, excerpt: 3]
import Legendary.Content.LayoutView, only: [title: 2, excerpt: 2]
def gravatar_url_for_email(email) do
email
|> Kernel.||("noreply@example.com")
|> String.trim()
|> String.downcase()
|> (&(:crypto.hash(:md5, &1))).()
|> Base.encode16()
|> String.downcase()
|> (&("https://www.gravatar.com/avatar/#{&1}")).()
end
def auto_paragraph_tags(string) do
string
|> Kernel.||("")
|> String.split(["\n\n", "\r\n\r\n"], trim: true)
|> Enum.map(fn text ->
[Tag.content_tag(:p, text |> HTML.raw(), []), ?\n]
end)
|> HTML.html_escape()
end
def post_class(post) do
sticky =
if post.sticky do
"sticky"
end
"post post-#{post.id} #{sticky}"
end
def post_topmatter(conn, post) do
author =
post.author ||
%Legendary.Auth.User{
email: "example@example.org",
display_name: "Anonymous",
homepage_url: "#"
}
assigns = %{post: post, author: author, conn: conn}
~E"""
<% _ = assigns # suppress unused assigns warning %>
<div class="Comment-topmatter">
<h4>
<%= link to: author.homepage_url || "#", rel: "author", class: "p-author h-card" do %>
<%= author.display_name %>
<%= img_tag gravatar_url_for_email(author.email), alt: "Photo of #{author.display_name}", class: "Gravatar u-photo" %>
<% end %>
</h4>
<h5>
<%= link to: Routes.posts_path(conn, :show, post) do %>
<time class="dt-published" datetime="<%= post.post %>">
<%= post.post |> Timex.format!("%F", :strftime) %>
</time>
<% end %>
</h5>
</div>
"""
end
def unauthenticated_post?(_conn, post) do
def unauthenticated_post?(post) do
post.password == nil || String.length(post.password) == 0
end
end

View file

@ -1,6 +1,8 @@
defmodule Legendary.Content.LayoutView do
use Legendary.Content, :view
def feed_tag(conn, assigns), do: feed_tag(conn, view_module(conn), view_template(conn), assigns)
def feed_tag(conn, view_module, view_template, assigns) do
~E"""
<link
@ -12,6 +14,8 @@ defmodule Legendary.Content.LayoutView do
"""
end
def title(conn, assigns), do: title(view_module(conn), view_template(conn), assigns)
def title(Legendary.Content.PostsView, "index.html", assigns) do
"Page #{assigns.page} | #{title(nil, nil, nil)}"
end
@ -26,6 +30,8 @@ defmodule Legendary.Content.LayoutView do
def title(_, _, _), do: Legendary.I18n.t! "en", "site.title"
def excerpt(conn, assigns), do: excerpt(view_module(conn), view_template(conn), assigns)
def excerpt(Legendary.Content.PostsView, "show.html", assigns) do
assigns.post.excerpt
|> HtmlSanitizeEx.strip_tags()

View file

@ -1,7 +1,7 @@
defmodule Legendary.Content.MixProject do
use Mix.Project
@version "2.11.5"
@version "4.3.0"
def project do
[
@ -11,7 +11,7 @@ defmodule Legendary.Content.MixProject do
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
elixir: "~> 1.7",
elixir: "~> 1.10",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
@ -46,23 +46,25 @@ defmodule Legendary.Content.MixProject do
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:floki, ">= 0.30.0"},
{:gettext, "~> 0.11"},
{:html_sanitize_ex, "~> 1.4.1"},
{:jason, "~> 1.0"},
{:mime, "~> 2.0.1"},
{:mock, "~> 0.3.0", only: :test},
{:meck, "~> 0.8.13", only: :test},
{:neotomex, "~> 0.1.7"},
{:oban, "~> 2.1"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_html_sanitizer, "~> 1.1.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4.0"},
{:oban, "~> 2.9"},
{:phoenix, "~> 1.6.0"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.7"},
{:phoenix_html, "~> 3.0.4", override: true},
{:phoenix_live_reload, "~> 1.3", only: :dev},
{:phoenix_live_dashboard, "~> 0.5.0"},
{:php_serializer, "~> 2.0.0"},
{:plug_cowboy, "~> 2.0"},
{:sitemap, "~> 1.1"},
{:slugger, "~> 0.3"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:telemetry_poller, "~> 1.0"},
{:timex, "~> 3.1"},
]
end

View file

@ -0,0 +1,36 @@
defmodule Legendary.Content.Posts.PreviewImagesTest do
use Legendary.Content.DataCase
import Legendary.Content.Posts.PreviewImages
alias Legendary.Content.Post
@png "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAMJlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAARAAAAcgEyAAIAAAAUAAAAhIdpAAQAAAABAAAAmAAAAAAAAABIAAAAAQAAAEgAAAABUGl4ZWxtYXRvciAzLjkuOAAAMjAyMTowOToyMiAxNTowOTowMQAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAAAYjzhKAAAACXBIWXMAAAsTAAALEwEAmpwYAAADpmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyI+CiAgICAgICAgIDx0aWZmOkNvbXByZXNzaW9uPjA8L3RpZmY6Q29tcHJlc3Npb24+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyPC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6T3JpZW50YXRpb24+MTwvdGlmZjpPcmllbnRhdGlvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjE8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjE8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPlBpeGVsbWF0b3IgMy45Ljg8L3htcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMjEtMDktMjJUMTU6MDk6MDE8L3htcDpNb2RpZnlEYXRlPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4K/LzAdAAAAAxJREFUCB1j+P//PwAF/gL+n8otEwAAAABJRU5ErkJggg=="
@bad_png "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII%3D"
@path "priv/test/static/public_uploads/content/posts/preview_images/preview-image-test/original.png"
describe "handle_preview_image_upload/2" do
test "stores an image given as a data uri" do
File.rm(@path)
changeset =
%Post{name: "preview-image-test", status: :draft}
|> Post.changeset()
assert handle_preview_image_upload(changeset, %{"social_media_preview_image" => @png}).valid?
assert File.exists?(@path)
end
test "adds a validation error if the image is invalid" do
File.rm(@path)
changeset =
%Post{name: "bad-image-test", status: :draft}
|> Post.changeset()
refute handle_preview_image_upload(changeset, %{"social_media_preview_image" => @bad_png}).valid?
refute File.exists?(@path)
end
end
end

View file

@ -45,14 +45,38 @@ defmodule Legendary.Content.PostsTest do
assert %Post{} = Posts.get_post_with_drafts!(Integer.to_string(id))
end
test "update_posts/2", %{public_post: post} do
assert {:ok, %Post{content: "boop"}} = Posts.update_posts(post, %{content: "boop"})
describe "update_posts/2" do
setup do
post_with_guid =
%Post{
title: "Post with guid",
name: "post-with-guid",
status: "publish",
type: "post",
guid: "/beep",
date: ~N[2020-01-01T00:00:00],
}
|> Repo.insert!()
%{
post_with_guid: post_with_guid
}
end
test "with no guid", %{public_post: post} do
assert {:ok, %Post{content: "boop"}} = Posts.update_posts(post, %{content: "boop"})
end
test "with an existing guid", %{post_with_guid: post} do
revised_post = Posts.update_posts(post, %{content: "boop"})
assert {:ok, %Post{content: "boop", guid: "/beep"}} = revised_post
end
end
test "delete_posts/1", %{public_post: post} do
assert Enum.count(Posts.list_posts()) == 1
assert {:ok, _} = Posts.delete_posts(post)
assert Enum.count(Posts.list_posts()) == 0
assert Enum.empty?(Posts.list_posts())
end
test "change_posts/1", %{public_post: post} do

View file

@ -0,0 +1,38 @@
defmodule Legendary.Content.SitemapStorageTest do
use Legendary.Content.DataCase
import Legendary.Content.SitemapStorage
alias Sitemap.Location
alias Legendary.Content.{Post, Repo}
test "creates a post with the content" do
data = "<hello />"
content = data |> :zlib.gzip() |> Base.encode64
write(:file, data)
path = Location.filename(:file)
post = from(p in Post, where: p.name == ^path) |> Repo.one()
assert post.content == content
end
test "updates an existing sitemap" do
path = Location.filename(:file)
%Post{
content: "<hello />" |> :zlib.gzip() |> Base.encode64,
name: path
}
|> Repo.insert!()
new_data = "<world />"
new_content = new_data |> :zlib.gzip() |> Base.encode64
write(:file, new_data)
post = from(p in Post, where: p.name == ^path) |> Repo.one()
assert post.content == new_content
end
end

View file

@ -0,0 +1,78 @@
defmodule Legendary.Content.SitemapsTest do
use Legendary.Content.DataCase
alias Legendary.Content.{Post, Repo}
import Mock
import Legendary.Content.Sitemaps
@xml ~s(
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'
xmlns:geo='http://www.google.com/geo/schemas/sitemap/1.0'
xmlns:news='http://www.google.com/schemas/sitemap-news/0.9'
xmlns:image='http://www.google.com/schemas/sitemap-image/1.1'
xmlns:video='http://www.google.com/schemas/sitemap-video/1.1'
xmlns:mobile='http://www.google.com/schemas/sitemap-mobile/1.0'
xmlns:pagemap='http://www.google.com/schemas/sitemap-pagemap/1.0'
xmlns:xhtml='http://www.w3.org/1999/xhtml'
>
<url>
<loc>https://localhost</loc>
<lastmod>2021-08-19T21:44:37Z</lastmod>
<changefreq>hourly</changefreq>
<priority>0.5</priority>
</url><url>
<loc>https://localhost/public-post</loc>
<lastmod>2021-08-19T21:44:37Z</lastmod>
<changefreq>hourly</changefreq>
<priority>0.5</priority>
</url><url>
<loc>https://localhost/public-post?page=2</loc>
<lastmod>2021-08-19T21:44:37Z</lastmod>
<changefreq>hourly</changefreq>
<priority>0.5</priority>
</url></urlset>
)
describe "generate/0" do
setup do
public_post =
%Post{
title: "Public post",
name: "public-post",
status: "publish",
type: "post",
date: ~N[2020-01-01T00:00:00],
content: """
Page 1
<!--nextpage-->
Page 2
"""
}
|> Repo.insert!()
%{
public_post: public_post,
}
end
test "generates results" do
with_mock Sitemap.Funcs, [
iso8601: fn -> "2021-08-19T21:44:37Z" end,
iso8601: & &1,
eraser: fn (elm) -> passthrough([elm]) end
] do
with_mock Legendary.Content.SitemapStorage, [write: fn (_name, _data) -> :ok end] do
assert :ok = perform(%{})
assert_called Legendary.Content.SitemapStorage.write(:file, String.trim(@xml))
end
end
end
end
end

View file

@ -24,7 +24,7 @@ defmodule Legendary.Content.CommentControllerTest do
post = fixture(:post)
conn = post conn, Routes.comment_path(conn, :create), comment: @create_attrs
assert %{id: id} = redirected_params(conn)
assert %{id: _} = redirected_params(conn)
assert redirected_to(conn) == Routes.posts_path(conn, :show, post)
end

View file

@ -1,7 +1,7 @@
defmodule Legendary.Content.PostsControllerTest do
use Legendary.Content.ConnCase
alias Legendary.Content.{Comment, Options, Posts, Repo, Term, TermRelationship, TermTaxonomy}
alias Legendary.Content.{Comment, Options, Post, Posts, Repo, Term, TermRelationship, TermTaxonomy}
@create_attrs %{
id: 123,
@ -165,6 +165,21 @@ defmodule Legendary.Content.PostsControllerTest do
assert html_response(conn, 200)
end
test "shows the post if the id has slashes", %{conn: conn} do
%Post{
name: "a/b/c",
content: "slashed id",
status: "publish",
type: "post",
date: ~N[2020-01-01T00:00:00]
}
|> Repo.insert!()
conn = get conn, Routes.nested_posts_path(conn, :show, ["a", "b", "c"])
assert html_response(conn, 200)
end
test "show a 404 if there's no match", %{conn: conn} do
assert_raise Phoenix.Router.NoRouteError, fn ->
get conn, Routes.posts_path(conn, :show, "blooper")

View file

@ -0,0 +1,17 @@
defmodule Legendary.ContentWeb.Uploaders.SocialMediaPreviewTest do
use Legendary.Content.DataCase
import Legendary.ContentWeb.Uploaders.SocialMediaPreview
describe "filename/2" do
test "" do
assert filename(:original, {%{file_name: "original.png"}, nil}) =~ "original"
end
end
describe "storage_dir/2" do
test "" do
assert storage_dir(nil, {nil, %{name: "test-slug"}}) =~ "public_uploads/content/posts/preview_images/test-slug"
end
end
end

View file

@ -0,0 +1,29 @@
defmodule Legendary.Content.ErrorHelpersTest do
use Legendary.Content.DataCase
import Phoenix.HTML, only: [safe_to_string: 1]
import Phoenix.HTML.Form, only: [form_for: 3]
import Legendary.Content.ErrorHelpers
def form do
:example
|> form_for(
"/example",
as: :test_params,
errors: [error_field: {"is an error", []}]
)
end
describe "error_tag/2" do
test "generates a span with an invalid-feedback class" do
[safe] = error_tag(form(), :error_field)
assert safe_to_string(safe) =~ "invalid-feedback"
end
test "error_tag/3" do
[safe] = error_tag(form(), :error_field, class: "test-class")
assert safe_to_string(safe) =~ "test-class"
end
end
end

View file

@ -0,0 +1,17 @@
defmodule Legendary.Content.FeedsViewTest do
use Legendary.Content.DataCase
import Legendary.Content.FeedsView
alias Legendary.Content.Post
describe "unauthenticated_post?/1" do
test "with post password" do
refute unauthenticated_post?(%Post{password: "password"})
end
test "without post password" do
assert unauthenticated_post?(%Post{})
end
end
end

View file

@ -4,6 +4,8 @@ defmodule Legendary.Content.PostsViewTest do
import Legendary.Content.PostsView
import Phoenix.HTML, only: [safe_to_string: 1]
alias Legendary.Content.Post
test "auto_paragraph_tags/1 with nil" do
assert safe_to_string(auto_paragraph_tags(nil)) =~ ""
end
@ -11,4 +13,22 @@ defmodule Legendary.Content.PostsViewTest do
test "auto_paragraph_tags/1 with text" do
assert safe_to_string(auto_paragraph_tags("Bloop\n\nBloop")) =~ "<p>Bloop</p>\n<p>Bloop</p>"
end
describe "authenticated_for_post?/2" do
test "without password" do
assert authenticated_for_post?(nil, %Post{})
end
test "with post password that matches", %{conn: conn} do
with_mock Plug.Conn, [get_session: fn (_conn, :post_password) -> "password" end] do
assert authenticated_for_post?(conn, %Post{password: "password"})
end
end
test "with post password that does not match", %{conn: conn} do
with_mock Plug.Conn, [get_session: fn (_conn, :post_password) -> "password" end] do
refute authenticated_for_post?(conn, %Post{password: "password2"})
end
end
end
end

View file

3
apps/core/cypress.json Normal file
View file

@ -0,0 +1,3 @@
{
"baseUrl": "http://localhost:4002"
}

View file

@ -0,0 +1,5 @@
describe('My First Test', () => {
it('Does not do much!', () => {
expect(true).to.equal(true)
})
})

View file

@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View file

@ -0,0 +1,36 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add("setupDB", (app, seedSet) => {
cy.request('POST', '/end-to-end/db/setup', {
app,
seed_set: seedSet,
}).as('setupDB')
})
Cypress.Commands.add("teardownDB", () => {
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
})

View file

@ -0,0 +1,29 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
before(() => {
// Make sure we don't have a rogue DB connection checked out
cy.teardownDB()
})
afterEach(() => {
cy.teardownDB()
})

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

Some files were not shown because too many files have changed in this diff Show more