mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into tweak-shortcuts
This commit is contained in:
@@ -11,3 +11,11 @@ trim_trailing_whitespace = true
|
||||
[{package.json,*.yml,*.yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Dockerfile]
|
||||
indent_size = 2
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_size = 2
|
||||
indent_style = tab
|
||||
|
||||
25
.github/actions/Makefile
vendored
Normal file
25
.github/actions/Makefile
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
SHELL=bash
|
||||
|
||||
version=v9.0.0-beta.1
|
||||
tag=$(version)
|
||||
cmd=
|
||||
user=directus
|
||||
registry=ghcr.io
|
||||
repository=directus/next
|
||||
|
||||
.PHONY: build
|
||||
|
||||
build-images:
|
||||
docker build \
|
||||
--build-arg VERSION=$(version) \
|
||||
--build-arg REPOSITORY=$(repository) \
|
||||
-t directus:temp \
|
||||
-f ./build-images/rootfs/directus/images/main/Dockerfile \
|
||||
./build-images/rootfs/directus/images/main
|
||||
|
||||
docker tag directus:temp $(registry)/$(repository):$(version)
|
||||
docker tag directus:temp $(registry)/$(repository):$(tag)
|
||||
docker image rm directus:temp
|
||||
|
||||
test-image:
|
||||
docker run --rm -it $(registry)/$(repository):$(tag) $(cmd)
|
||||
13
.github/actions/build-images/.editorconfig
vendored
Normal file
13
.github/actions/build-images/.editorconfig
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
tab_width = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
15
.github/actions/build-images/Dockerfile
vendored
Normal file
15
.github/actions/build-images/Dockerfile
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM docker:stable
|
||||
|
||||
RUN \
|
||||
apk update && \
|
||||
apk upgrade && \
|
||||
apk add bash
|
||||
|
||||
COPY ./rootfs/ /
|
||||
|
||||
RUN \
|
||||
chmod +x /usr/bin/lib/argsf && \
|
||||
chmod +x /usr/bin/entrypoint && \
|
||||
chmod +x /usr/bin/semver
|
||||
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
42
.github/actions/build-images/action.yml
vendored
Normal file
42
.github/actions/build-images/action.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: "Build and publish Directus images"
|
||||
description: "GitHub Action to publish Directus container images."
|
||||
branding:
|
||||
icon: archive
|
||||
color: gray-dark
|
||||
inputs:
|
||||
repository:
|
||||
description: "Repository name"
|
||||
required: true
|
||||
registry:
|
||||
description: "Registry"
|
||||
required: false
|
||||
default: ghcr.io
|
||||
username:
|
||||
description: "Registry user"
|
||||
required: true
|
||||
password:
|
||||
description: "Registry password"
|
||||
required: true
|
||||
version:
|
||||
description: "Version"
|
||||
required: true
|
||||
push:
|
||||
description: "Push"
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
args:
|
||||
- --registry
|
||||
- ${{ inputs.registry }}
|
||||
- --repository
|
||||
- ${{ inputs.repository }}
|
||||
- --username
|
||||
- ${{ inputs.username }}
|
||||
- --password
|
||||
- ${{ inputs.password }}
|
||||
- --version
|
||||
- ${{ inputs.version }}
|
||||
- --push
|
||||
- ${{ inputs.push }}
|
||||
2
.github/actions/build-images/rootfs/directus/images/main/.dockerignore
vendored
Normal file
2
.github/actions/build-images/rootfs/directus/images/main/.dockerignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
13
.github/actions/build-images/rootfs/directus/images/main/.editorconfig
vendored
Normal file
13
.github/actions/build-images/rootfs/directus/images/main/.editorconfig
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
tab_width = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
94
.github/actions/build-images/rootfs/directus/images/main/Dockerfile
vendored
Normal file
94
.github/actions/build-images/rootfs/directus/images/main/Dockerfile
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
#
|
||||
# Builder
|
||||
#
|
||||
|
||||
FROM node:14-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
RUN \
|
||||
apk update && \
|
||||
apk upgrade && \
|
||||
apk add jq
|
||||
|
||||
WORKDIR /directus
|
||||
|
||||
COPY package.json .
|
||||
RUN \
|
||||
jq ".dependencies.directus = \"^${VERSION}\"" package.json > updated.json && \
|
||||
mv updated.json package.json
|
||||
|
||||
RUN cat package.json
|
||||
|
||||
#
|
||||
# Image
|
||||
#
|
||||
FROM node:14-alpine
|
||||
|
||||
ARG VERSION
|
||||
ARG REPOSITORY=directus/directus
|
||||
|
||||
LABEL directus.version="${VERSION}"
|
||||
LABEL org.opencontainers.image.source https://github.com/${REPOSITORY}
|
||||
|
||||
ENV \
|
||||
PORT="41201" \
|
||||
PUBLIC_URL="/" \
|
||||
DB_CLIENT="sqlite3" \
|
||||
DB_FILENAME="/directus/database/database.sqlite" \
|
||||
RATE_LIMITER_ENABLED="false" \
|
||||
RATE_LIMITER_STORE="memory" \
|
||||
RATE_LIMITER_POINTS="25" \
|
||||
RATE_LIMITER_DURATION="1" \
|
||||
CACHE_ENABLED="false" \
|
||||
STORAGE_LOCATIONS="local" \
|
||||
STORAGE_LOCAL_PUBLIC_URL="/uploads" \
|
||||
STORAGE_LOCAL_DRIVER="local" \
|
||||
STORAGE_LOCAL_ROOT="/directus/uploads" \
|
||||
ACCESS_TOKEN_TTL="15m" \
|
||||
REFRESH_TOKEN_TTL="7d" \
|
||||
REFRESH_TOKEN_COOKIE_SECURE="false" \
|
||||
REFRESH_TOKEN_COOKIE_SAME_SITE="lax" \
|
||||
OAUTH_PROVIDERS="" \
|
||||
EXTENSIONS_PATH="/directus/extensions" \
|
||||
EMAIL_FROM="no-reply@directus.io" \
|
||||
EMAIL_TRANSPORT="sendmail" \
|
||||
EMAIL_SENDMAIL_NEW_LINE="unix" \
|
||||
EMAIL_SENDMAIL_PATH="/usr/sbin/sendmail"
|
||||
|
||||
RUN \
|
||||
apk update && \
|
||||
apk upgrade && \
|
||||
apk add bash ssmtp util-linux
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
WORKDIR /directus
|
||||
|
||||
# Global requirements
|
||||
RUN npm install -g yargs pino pino-colada
|
||||
|
||||
# Install Directus
|
||||
COPY --from=builder /directus/package.json .
|
||||
RUN npm install
|
||||
|
||||
# Copy files
|
||||
COPY ./rootfs /
|
||||
RUN chmod +x /usr/bin/entrypoint && chmod +x /usr/bin/print
|
||||
|
||||
# Create directories
|
||||
RUN \
|
||||
mkdir -p extensions/displays && \
|
||||
mkdir -p extensions/interfaces && \
|
||||
mkdir -p extensions/layouts && \
|
||||
mkdir -p extensions/modules && \
|
||||
mkdir -p database && \
|
||||
mkdir -p uploads
|
||||
|
||||
EXPOSE 41201
|
||||
VOLUME \
|
||||
/directus/database \
|
||||
/directus/extensions \
|
||||
/directus/uploads
|
||||
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
22
.github/actions/build-images/rootfs/directus/images/main/package.json
vendored
Normal file
22
.github/actions/build-images/rootfs/directus/images/main/package.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "directus-project",
|
||||
"version": "1.0.0",
|
||||
"description": "Directus Project",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/redis": "^2.1.2",
|
||||
"directus": "^9.0.0-beta.1",
|
||||
"ioredis": "^4.17.3",
|
||||
"memcached": "^2.2.2",
|
||||
"mssql": "^6.2.2",
|
||||
"mysql": "^2.18.1",
|
||||
"oracledb": "^5.0.0",
|
||||
"pg": "^8.3.3",
|
||||
"sqlite3": "^5.0.0",
|
||||
"yargs": "^16.0.3"
|
||||
}
|
||||
}
|
||||
110
.github/actions/build-images/rootfs/directus/images/main/rootfs/usr/bin/entrypoint
vendored
Normal file
110
.github/actions/build-images/rootfs/directus/images/main/rootfs/usr/bin/entrypoint
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
function seed() {
|
||||
# TODO: move users to a separate check, outside database installation
|
||||
local show=false
|
||||
local email=${DIRECTUS_ADMIN_EMAIL:-"admin@example.com"}
|
||||
local password=${DIRECTUS_ADMIN_PASSWORD:-""}
|
||||
|
||||
if [ "${password}" == "" ] ; then
|
||||
password=$(node -e 'console.log(require("nanoid").nanoid(12))')
|
||||
show=true
|
||||
fi
|
||||
|
||||
print --level=info "Creating administrator role"
|
||||
local role=$(npx directus roles create --name Administrator --admin)
|
||||
|
||||
print --level=info "Creating administrator user"
|
||||
local user=$(npx directus users create --email "${email}" --password "${password}" --role "${role}")
|
||||
|
||||
if [ "${show}" == "true" ] ; then
|
||||
print --level=info --stdin <<MSG
|
||||
>
|
||||
> Email: $email
|
||||
> Password: $password
|
||||
>
|
||||
MSG
|
||||
else
|
||||
print --level=info --stdin <<MSG
|
||||
>
|
||||
> Email: $email
|
||||
> Password: <env>
|
||||
>
|
||||
MSG
|
||||
fi
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
local warn=false
|
||||
|
||||
if [ "${KEY}" == "" ] ; then
|
||||
export KEY=$(uuidgen)
|
||||
warn=true
|
||||
fi
|
||||
|
||||
if [ "${SECRET}" == "" ] ; then
|
||||
export SECRET=$(node -e 'console.log(require("nanoid").nanoid(32))')
|
||||
warn=true
|
||||
fi
|
||||
|
||||
if [ "${warn}" == "true" ] ; then
|
||||
print --level=warn --stdin <<WARN
|
||||
>
|
||||
> WARNING!
|
||||
>
|
||||
> The KEY and SECRET environment variables are not set.
|
||||
> Some temporar
|
||||
y variables were generated to fill the gap,
|
||||
> but in production this is going to cause problems.
|
||||
>
|
||||
> Please refer to the docs at https://docs.directus.io/
|
||||
> on how and why to configure them properly
|
||||
>
|
||||
WARN
|
||||
fi
|
||||
|
||||
# Install database if using sqlite and file doesn't exist
|
||||
if [ "${DB_CLIENT}" == "sqlite3" ] ; then
|
||||
if [ "${DB_FILENAME}" == "" ] ; then
|
||||
print --level=error "Missing DB_FILENAME environment variable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${DB_FILENAME}" ] ; then
|
||||
mkdir -p $(dirname ${DB_FILENAME})
|
||||
fi
|
||||
fi
|
||||
|
||||
should_seed=false
|
||||
|
||||
set +e
|
||||
npx directus database install 2>&1 /dev/null
|
||||
if [ "$?" == "0" ] ; then
|
||||
print --level=info "Database installed"
|
||||
should_seed=true
|
||||
fi
|
||||
set -e
|
||||
|
||||
if [ "${should_seed}" == "true" ] ; then
|
||||
seed
|
||||
fi
|
||||
}
|
||||
|
||||
command=""
|
||||
if [ $# -eq 0 ] ; then
|
||||
command="start"
|
||||
elif [ "${1}" == "bash" ] || [ "${1}" == "shell" ] ; then
|
||||
shift
|
||||
exec bash $@
|
||||
elif [ "${1}" == "command" ] ; then
|
||||
shift
|
||||
exec $@
|
||||
else
|
||||
command="${1}"
|
||||
shift
|
||||
fi
|
||||
|
||||
bootstrap
|
||||
exec npx directus "${command}" $@
|
||||
48
.github/actions/build-images/rootfs/directus/images/main/rootfs/usr/bin/print
vendored
Normal file
48
.github/actions/build-images/rootfs/directus/images/main/rootfs/usr/bin/print
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Workarounds?
|
||||
process.env.NODE_PATH = "/usr/local/lib/node_modules";
|
||||
require("module").Module._initPaths();
|
||||
|
||||
/**
|
||||
* Read lines from stdin
|
||||
*/
|
||||
async function readlines() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const lines = chunks.join("").split("\n");
|
||||
lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
(async function () {
|
||||
// Logger
|
||||
const yargs = require("yargs");
|
||||
const logger = require("pino")({
|
||||
prettyPrint: process.env.LOG_STYLE !== "raw",
|
||||
prettifier: require("pino-colada"),
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
});
|
||||
|
||||
function write(...message) {
|
||||
if (level in logger) {
|
||||
logger[level](...message);
|
||||
} else {
|
||||
logger.info(...message);
|
||||
}
|
||||
}
|
||||
|
||||
const args = yargs.argv;
|
||||
const level = args.level || "info";
|
||||
const stdin = args.stdin || false;
|
||||
|
||||
if (stdin) {
|
||||
const lines = await readlines();
|
||||
lines.forEach((line) => write(line));
|
||||
} else {
|
||||
write(...args._);
|
||||
}
|
||||
})();
|
||||
133
.github/actions/build-images/rootfs/usr/bin/entrypoint
vendored
Normal file
133
.github/actions/build-images/rootfs/usr/bin/entrypoint
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
root=$(dirname ${0})
|
||||
source ${root}/lib/argsf
|
||||
|
||||
#
|
||||
# Makes a set of tags
|
||||
#
|
||||
function make_tags() {
|
||||
local prefix=""
|
||||
local version=${1}
|
||||
|
||||
semver get major ${version} > /dev/null 2>&1
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "${version}"
|
||||
else
|
||||
if [ "${version:0:1}" == "v" ]; then
|
||||
prefix="v"
|
||||
fi
|
||||
|
||||
major="$(semver get major ${version})"
|
||||
minor="${major}.$(semver get minor ${version})"
|
||||
patch="${minor}.$(semver get patch ${version})"
|
||||
|
||||
prerel="$(semver get prerel ${version})"
|
||||
if [ "${prerel}" == "" ]; then
|
||||
is_prerel=false
|
||||
else
|
||||
is_prerel=true
|
||||
fi
|
||||
|
||||
build="$(semver get build ${version})"
|
||||
if [ "${build}" == "" ]; then
|
||||
is_build=false
|
||||
else
|
||||
is_build=true
|
||||
fi
|
||||
|
||||
if [ "${is_prerel}" == "true" ]; then
|
||||
echo "${prefix}${major}-${prerel}"
|
||||
echo "${prefix}${minor}-${prerel}"
|
||||
echo "${prefix}${patch}-${prerel}"
|
||||
if [ "${is_build}" == "true" ]; then
|
||||
echo "${prefix}${major}-${prerel}-${build}"
|
||||
fi
|
||||
else
|
||||
echo "${prefix}${major}"
|
||||
echo "${prefix}${minor}"
|
||||
echo "${prefix}${patch}"
|
||||
if [ "${is_build}" == "true" ]; then
|
||||
echo "${prefix}${patch}-${build}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# Build script
|
||||
#
|
||||
function main() {
|
||||
username=$(argument username)
|
||||
password=$(argument password)
|
||||
|
||||
push=$(argument push "false")
|
||||
latest=$(argument latest "false")
|
||||
|
||||
registry=$(argument registry "ghcr.io")
|
||||
registry=$(echo "${registry}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
repository=$(argument repository "directus/next")
|
||||
repository=$(echo "${repository}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
version=$(argument version "")
|
||||
context=$(argument context ".")
|
||||
|
||||
# Normalize tag
|
||||
if [ "${version}" == "" ]; then
|
||||
version=${GITHUB_REF##*/}
|
||||
else
|
||||
version=${version##*/}
|
||||
fi
|
||||
|
||||
if [ "${version}" == "" ]; then
|
||||
version=$(echo ${GITHUB_SHA:-"000000000000"} | cut -c1-12)
|
||||
fi
|
||||
|
||||
tags=$(make_tags ${version})
|
||||
echo "Tags = ${tags}"
|
||||
|
||||
# build image
|
||||
docker build \
|
||||
-t directus:main \
|
||||
--build-arg VERSION=${version} \
|
||||
--build-arg REPOSITORY=${repository} \
|
||||
/directus/images/main
|
||||
|
||||
# login into registry
|
||||
docker login -u "${username}" -p "${password}" "${registry}"
|
||||
|
||||
# Push latest
|
||||
# TODO: check if it's really the latest
|
||||
if [ "${latest}" == "true" ]; then
|
||||
fqin="${registry}/${repository}:latest"
|
||||
echo "Tagging ${fqin}"
|
||||
docker tag directus:main ${fqin}
|
||||
if [ "${push}" == "true" ]; then
|
||||
echo "Pushing tag ${fqin}"
|
||||
docker push "${fqin}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Push tags
|
||||
for tag in $tags
|
||||
do
|
||||
tag=$(echo "${tag}" | tr '[:upper:]' '[:lower:]')
|
||||
fqin="${registry}/${repository}:latest"
|
||||
echo "Tagging ${fqin}"
|
||||
docker tag directus:main "${registry}/${repository}:${tag}"
|
||||
if [ "${push}" == "true" ]; then
|
||||
echo "Pushing tag ${fqin}"
|
||||
docker push "${registry}/${repository}:${tag}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Finished."
|
||||
|
||||
exit $?
|
||||
}
|
||||
|
||||
main
|
||||
exit $?
|
||||
98
.github/actions/build-images/rootfs/usr/bin/lib/argsf
vendored
Normal file
98
.github/actions/build-images/rootfs/usr/bin/lib/argsf
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
#
|
||||
# Arguments and Flags (argsf)
|
||||
# This is meant to work with bash shell
|
||||
# To use, source this file into your bash scripts
|
||||
#
|
||||
# Implemented by João Biondo <wolfulus@gmail.com>
|
||||
# https://github.com/WoLfulus/argsf
|
||||
#
|
||||
|
||||
declare _ARGCOUNT=$#
|
||||
declare _ARGDATA=("$@")
|
||||
declare -A _ARGMAP
|
||||
declare -A _FLAGMAP
|
||||
|
||||
for ((_arg_index_key=1;_arg_index_key<=$#;_arg_index_key++))
|
||||
do
|
||||
_arg_index_value=$(expr $_arg_index_key + 1)
|
||||
_arg_key=${!_arg_index_key}
|
||||
_arg_value=${!_arg_index_value}
|
||||
if [[ $_arg_key == *"--"* ]]; then
|
||||
if [[ $_arg_key == *" "* ]]; then
|
||||
continue
|
||||
fi
|
||||
_arg_name="${_arg_key:2}"
|
||||
_FLAGMAP[${_arg_name}]=1
|
||||
if [[ $_arg_value != *"--"* ]] || [[ $_arg_value == *" "* ]] ; then
|
||||
_ARGMAP[${_arg_name}]="$_arg_value"
|
||||
else
|
||||
_ARGMAP[${_arg_name}]=""
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
function _argument() {
|
||||
if test "${_ARGMAP[${ARG_NAME}]+isset}" ; then
|
||||
echo ${_ARGMAP[${ARG_NAME}]}
|
||||
else
|
||||
if [ ${ARG_DEFAULT} -eq 0 ]; then
|
||||
echo "Error: required argument '--${ARG_NAME}' not specified" 1>&2
|
||||
exit 1
|
||||
else
|
||||
echo ${ARG_DEFAULT_VALUE}
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function argument() {
|
||||
if [ $# -eq 1 ]; then
|
||||
ARG_NAME="$1" ARG_DEFAULT=0 ARG_DEFAULT_VALUE= _argument "${_ARGUMENT_DATA}"
|
||||
elif [ $# -eq 2 ]; then
|
||||
ARG_NAME="$1" ARG_DEFAULT=1 ARG_DEFAULT_VALUE="$2" _argument "${_ARGUMENT_DATA}"
|
||||
else
|
||||
echo "argument: invalid number of arguments" 1>&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
function flage() {
|
||||
if [ $# -eq 1 ]; then
|
||||
if [[ ${_FLAGMAP[$1]} ]] ; then
|
||||
echo "true"
|
||||
return 0
|
||||
elif [[ ${_FLAGMAP[no-$1]} ]] ; then
|
||||
echo "false"
|
||||
return 0
|
||||
else
|
||||
echo "true"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "flag: invalid number of arguments" 1>&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function flagd() {
|
||||
if [ $# -eq 1 ]; then
|
||||
if [[ ${_FLAGMAP[$1]} ]] ; then
|
||||
echo "true"
|
||||
return 0
|
||||
elif [[ ${_FLAGMAP[no-$1]} ]] ; then
|
||||
echo "false"
|
||||
return 0
|
||||
else
|
||||
echo "false"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "flag: invalid number of arguments" 1>&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function flag() {
|
||||
flagd $1
|
||||
return $?
|
||||
}
|
||||
284
.github/actions/build-images/rootfs/usr/bin/semver
vendored
Normal file
284
.github/actions/build-images/rootfs/usr/bin/semver
vendored
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2015 François Saint-Jacques <fsaintjacques@gmail.com>
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation; either version 3, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
# PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
NAT='0|[1-9][0-9]*'
|
||||
ALPHANUM='[0-9]*[A-Za-z-][0-9A-Za-z-]*'
|
||||
IDENT="$NAT|$ALPHANUM"
|
||||
FIELD='[0-9A-Za-z-]+'
|
||||
|
||||
SEMVER_REGEX="\
|
||||
^[vV]?\
|
||||
($NAT)\\.($NAT)\\.($NAT)\
|
||||
(\\-(${IDENT})(\\.(${IDENT}))*)?\
|
||||
(\\+${FIELD}(\\.${FIELD})*)?$"
|
||||
|
||||
PROG=semver
|
||||
PROG_VERSION="3.0.0"
|
||||
|
||||
USAGE="\
|
||||
Usage:
|
||||
$PROG bump (major|minor|patch|release|prerel <prerel>|build <build>) <version>
|
||||
$PROG compare <version> <other_version>
|
||||
$PROG get (major|minor|patch|release|prerel|build) <version>
|
||||
$PROG --help
|
||||
$PROG --version
|
||||
Arguments:
|
||||
<version> A version must match the following regular expression:
|
||||
\"${SEMVER_REGEX}\"
|
||||
In English:
|
||||
-- The version must match X.Y.Z[-PRERELEASE][+BUILD]
|
||||
where X, Y and Z are non-negative integers.
|
||||
-- PRERELEASE is a dot separated sequence of non-negative integers and/or
|
||||
identifiers composed of alphanumeric characters and hyphens (with
|
||||
at least one non-digit). Numeric identifiers must not have leading
|
||||
zeros. A hyphen (\"-\") introduces this optional part.
|
||||
-- BUILD is a dot separated sequence of identifiers composed of alphanumeric
|
||||
characters and hyphens. A plus (\"+\") introduces this optional part.
|
||||
<other_version> See <version> definition.
|
||||
<prerel> A string as defined by PRERELEASE above.
|
||||
<build> A string as defined by BUILD above.
|
||||
Options:
|
||||
-v, --version Print the version of this tool.
|
||||
-h, --help Print this help message.
|
||||
Commands:
|
||||
bump Bump by one of major, minor, patch; zeroing or removing
|
||||
subsequent parts. \"bump prerel\" sets the PRERELEASE part and
|
||||
removes any BUILD part. \"bump build\" sets the BUILD part.
|
||||
\"bump release\" removes any PRERELEASE or BUILD parts.
|
||||
The bumped version is written to stdout.
|
||||
compare Compare <version> with <other_version>, output to stdout the
|
||||
following values: -1 if <other_version> is newer, 0 if equal, 1 if
|
||||
older. The BUILD part is not used in comparisons.
|
||||
get Extract given part of <version>, where part is one of major, minor,
|
||||
patch, prerel, build, or release.
|
||||
See also:
|
||||
https://semver.org -- Semantic Versioning 2.0.0"
|
||||
|
||||
function error {
|
||||
echo -e "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
function usage-help {
|
||||
error "$USAGE"
|
||||
}
|
||||
|
||||
function usage-version {
|
||||
echo -e "${PROG}: $PROG_VERSION"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function validate-version {
|
||||
local version=$1
|
||||
if [[ "$version" =~ $SEMVER_REGEX ]]; then
|
||||
# if a second argument is passed, store the result in var named by $2
|
||||
if [ "$#" -eq "2" ]; then
|
||||
local major=${BASH_REMATCH[1]}
|
||||
local minor=${BASH_REMATCH[2]}
|
||||
local patch=${BASH_REMATCH[3]}
|
||||
local prere=${BASH_REMATCH[4]}
|
||||
local build=${BASH_REMATCH[8]}
|
||||
eval "$2=(\"$major\" \"$minor\" \"$patch\" \"$prere\" \"$build\")"
|
||||
else
|
||||
echo "$version"
|
||||
fi
|
||||
else
|
||||
error "version $version does not match the semver scheme 'X.Y.Z(-PRERELEASE)(+BUILD)'. See help for more information."
|
||||
fi
|
||||
}
|
||||
|
||||
function is-nat {
|
||||
[[ "$1" =~ ^($NAT)$ ]]
|
||||
}
|
||||
|
||||
function is-null {
|
||||
[ -z "$1" ]
|
||||
}
|
||||
|
||||
function order-nat {
|
||||
[ "$1" -lt "$2" ] && { echo -1 ; return ; }
|
||||
[ "$1" -gt "$2" ] && { echo 1 ; return ; }
|
||||
echo 0
|
||||
}
|
||||
|
||||
function order-string {
|
||||
[[ $1 < $2 ]] && { echo -1 ; return ; }
|
||||
[[ $1 > $2 ]] && { echo 1 ; return ; }
|
||||
echo 0
|
||||
}
|
||||
|
||||
# given two (named) arrays containing NAT and/or ALPHANUM fields, compare them
|
||||
# one by one according to semver 2.0.0 spec. Return -1, 0, 1 if left array ($1)
|
||||
# is less-than, equal, or greater-than the right array ($2). The longer array
|
||||
# is considered greater-than the shorter if the shorter is a prefix of the longer.
|
||||
#
|
||||
function compare-fields {
|
||||
local l="$1[@]"
|
||||
local r="$2[@]"
|
||||
local leftfield=( "${!l}" )
|
||||
local rightfield=( "${!r}" )
|
||||
local left
|
||||
local right
|
||||
|
||||
local i=$(( -1 ))
|
||||
local order=$(( 0 ))
|
||||
|
||||
while true
|
||||
do
|
||||
[ $order -ne 0 ] && { echo $order ; return ; }
|
||||
|
||||
: $(( i++ ))
|
||||
left="${leftfield[$i]}"
|
||||
right="${rightfield[$i]}"
|
||||
|
||||
is-null "$left" && is-null "$right" && { echo 0 ; return ; }
|
||||
is-null "$left" && { echo -1 ; return ; }
|
||||
is-null "$right" && { echo 1 ; return ; }
|
||||
|
||||
is-nat "$left" && is-nat "$right" && { order=$(order-nat "$left" "$right") ; continue ; }
|
||||
is-nat "$left" && { echo -1 ; return ; }
|
||||
is-nat "$right" && { echo 1 ; return ; }
|
||||
{ order=$(order-string "$left" "$right") ; continue ; }
|
||||
done
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2206 # checked by "validate"; ok to expand prerel id's into array
|
||||
function compare-version {
|
||||
local order
|
||||
validate-version "$1" V
|
||||
validate-version "$2" V_
|
||||
|
||||
# compare major, minor, patch
|
||||
|
||||
local left=( "${V[0]}" "${V[1]}" "${V[2]}" )
|
||||
local right=( "${V_[0]}" "${V_[1]}" "${V_[2]}" )
|
||||
|
||||
order=$(compare-fields left right)
|
||||
[ "$order" -ne 0 ] && { echo "$order" ; return ; }
|
||||
|
||||
# compare pre-release ids when M.m.p are equal
|
||||
|
||||
local prerel="${V[3]:1}"
|
||||
local prerel_="${V_[3]:1}"
|
||||
local left=( ${prerel//./ } )
|
||||
local right=( ${prerel_//./ } )
|
||||
|
||||
# if left and right have no pre-release part, then left equals right
|
||||
# if only one of left/right has pre-release part, that one is less than simple M.m.p
|
||||
|
||||
[ -z "$prerel" ] && [ -z "$prerel_" ] && { echo 0 ; return ; }
|
||||
[ -z "$prerel" ] && { echo 1 ; return ; }
|
||||
[ -z "$prerel_" ] && { echo -1 ; return ; }
|
||||
|
||||
# otherwise, compare the pre-release id's
|
||||
|
||||
compare-fields left right
|
||||
}
|
||||
|
||||
function command-bump {
|
||||
local new; local version; local sub_version; local command;
|
||||
|
||||
case $# in
|
||||
2) case $1 in
|
||||
major|minor|patch|release) command=$1; version=$2;;
|
||||
*) usage-help;;
|
||||
esac ;;
|
||||
3) case $1 in
|
||||
prerel|build) command=$1; sub_version=$2 version=$3 ;;
|
||||
*) usage-help;;
|
||||
esac ;;
|
||||
*) usage-help;;
|
||||
esac
|
||||
|
||||
validate-version "$version" parts
|
||||
# shellcheck disable=SC2154
|
||||
local major="${parts[0]}"
|
||||
local minor="${parts[1]}"
|
||||
local patch="${parts[2]}"
|
||||
local prere="${parts[3]}"
|
||||
local build="${parts[4]}"
|
||||
|
||||
case "$command" in
|
||||
major) new="$((major + 1)).0.0";;
|
||||
minor) new="${major}.$((minor + 1)).0";;
|
||||
patch) new="${major}.${minor}.$((patch + 1))";;
|
||||
release) new="${major}.${minor}.${patch}";;
|
||||
prerel) new=$(validate-version "${major}.${minor}.${patch}-${sub_version}");;
|
||||
build) new=$(validate-version "${major}.${minor}.${patch}${prere}+${sub_version}");;
|
||||
*) usage-help ;;
|
||||
esac
|
||||
|
||||
echo "$new"
|
||||
exit 0
|
||||
}
|
||||
|
||||
function command-compare {
|
||||
local v; local v_;
|
||||
|
||||
case $# in
|
||||
2) v=$(validate-version "$1"); v_=$(validate-version "$2") ;;
|
||||
*) usage-help ;;
|
||||
esac
|
||||
|
||||
set +u # need unset array element to evaluate to null
|
||||
compare-version "$v" "$v_"
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
||||
# shellcheck disable=SC2034
|
||||
function command-get {
|
||||
local part version
|
||||
|
||||
if [[ "$#" -ne "2" ]] || [[ -z "$1" ]] || [[ -z "$2" ]]; then
|
||||
usage-help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
part="$1"
|
||||
version="$2"
|
||||
|
||||
validate-version "$version" parts
|
||||
local major="${parts[0]}"
|
||||
local minor="${parts[1]}"
|
||||
local patch="${parts[2]}"
|
||||
local prerel="${parts[3]:1}"
|
||||
local build="${parts[4]:1}"
|
||||
local release="${major}.${minor}.${patch}"
|
||||
|
||||
case "$part" in
|
||||
major|minor|patch|release|prerel|build) echo "${!part}" ;;
|
||||
*) usage-help ;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
case $# in
|
||||
0) echo "Unknown command: $*"; usage-help;;
|
||||
esac
|
||||
|
||||
case $1 in
|
||||
--help|-h) echo -e "$USAGE"; exit 0;;
|
||||
--version|-v) usage-version ;;
|
||||
bump) shift; command-bump "$@";;
|
||||
get) shift; command-get "$@";;
|
||||
compare) shift; command-compare "$@";;
|
||||
*) echo "Unknown arguments: $*"; usage-help;;
|
||||
esac
|
||||
22
.github/workflows/build-images.yml
vendored
Normal file
22
.github/workflows/build-images.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: build-images
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build
|
||||
uses: ./.github/actions/build-images
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
repository: "${{ github.repository }}"
|
||||
username: "${{ secrets.REGISTRY_USERNAME }}"
|
||||
password: "${{ secrets.REGISTRY_PASSWORD }}"
|
||||
version: "${{ github.ref }}"
|
||||
push: "true"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
node_modules
|
||||
.vs_code
|
||||
.env
|
||||
.secrets
|
||||
npm-debug.log
|
||||
lerna-debug.log
|
||||
.nova
|
||||
*.code-workspace
|
||||
|
||||
25939
api/package-lock.json
generated
25939
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "directus",
|
||||
"version": "9.0.0-beta.1",
|
||||
"version": "9.0.0-beta.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"homepage": "https://github.com/directus/next#readme",
|
||||
"description": "Directus is a real-time API and App dashboard for managing SQL database content.",
|
||||
@@ -64,7 +64,7 @@
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-beta.1",
|
||||
"@directus/app": "^9.0.0-beta.2",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
"@slynova/flydrive-gcs": "^1.0.2",
|
||||
@@ -79,6 +79,7 @@
|
||||
"commander": "^5.1.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^2.16.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"eventemitter2": "^6.4.3",
|
||||
"execa": "^4.0.3",
|
||||
@@ -97,7 +98,7 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"keyv": "^4.0.1",
|
||||
"knex": "^0.21.4",
|
||||
"knex-schema-inspector": "0.0.12",
|
||||
"knex-schema-inspector": "0.0.20",
|
||||
"liquidjs": "^9.14.1",
|
||||
"lodash": "^4.17.19",
|
||||
"macos-release": "^2.4.1",
|
||||
|
||||
15
api/src/cli/commands/count/index.ts
Normal file
15
api/src/cli/commands/count/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default async function rolesCreate(collection: string) {
|
||||
const database = require('../../../database/index').default;
|
||||
|
||||
if (!collection) {
|
||||
console.error('Collection is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const records = await database(collection).count('*', { as: 'count' });
|
||||
const count = Number(records[0].count);
|
||||
|
||||
console.log(count);
|
||||
|
||||
database.destroy();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export default async function rolesCreate({ name, admin }: any) {
|
||||
const database = require('../../../database/index').default;
|
||||
const RolesService = require('../../../services/roles').default;
|
||||
const { RolesService } = require('../../../services/roles');
|
||||
|
||||
if (!name) {
|
||||
console.error('Name is required');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logger from '../../logger';
|
||||
import { Express } from 'express';
|
||||
|
||||
export default async function start() {
|
||||
const { default: env } = require('../../env');
|
||||
@@ -6,11 +7,24 @@ export default async function start() {
|
||||
|
||||
await validateDBConnection();
|
||||
|
||||
const app = require('../../app').default;
|
||||
const app: Express = require('../../app').default;
|
||||
|
||||
const port = env.PORT;
|
||||
|
||||
app.listen(port, () => {
|
||||
const server = app.listen(port, () => {
|
||||
logger.info(`Server started at port ${port}`);
|
||||
});
|
||||
|
||||
const signals: NodeJS.Signals[] = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
||||
signals.forEach((signal) => {
|
||||
process.on(signal, () =>
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
logger.error(err.message, { err });
|
||||
return;
|
||||
}
|
||||
logger.info('Server stopped.');
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default async function usersCreate({ email, password, role }: any) {
|
||||
const database = require('../../../database/index').default;
|
||||
const UsersService = require('../../../services/users').default;
|
||||
const { UsersService } = require('../../../services/users');
|
||||
|
||||
if (!email || !password || !role) {
|
||||
console.error('Email, password, role are required');
|
||||
|
||||
@@ -10,6 +10,7 @@ import dbInstall from './commands/database/install';
|
||||
import dbMigrate from './commands/database/migrate';
|
||||
import usersCreate from './commands/users/create';
|
||||
import rolesCreate from './commands/roles/create';
|
||||
import count from './commands/count';
|
||||
|
||||
program.name('directus').usage('[command] [options]');
|
||||
program.version(pkg.version, '-v, --version');
|
||||
@@ -51,4 +52,9 @@ rolesCommand
|
||||
.option('--admin', `whether or not the role has admin access`)
|
||||
.action(rolesCreate);
|
||||
|
||||
program
|
||||
.command('count <collection>')
|
||||
.description('Count the amount of items in a given collection')
|
||||
.action(count);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -3,13 +3,14 @@ import session from 'express-session';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import Joi from 'joi';
|
||||
import grant from 'grant';
|
||||
import getGrantConfig from '../utils/get-grant-config';
|
||||
import getEmailFromProfile from '../utils/get-email-from-profile';
|
||||
import { InvalidPayloadException } from '../exceptions/invalid-payload';
|
||||
import ms from 'ms';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import env from '../env';
|
||||
import { UsersService, AuthenticationService } from '../services';
|
||||
import grantConfig from '../grant';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -203,19 +204,41 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get('/oauth', asyncHandler(async (req, res, next) => {
|
||||
const providers = env.OAUTH_PROVIDERS.split(',');
|
||||
res.locals.payload = { data: providers };
|
||||
return next();
|
||||
}));
|
||||
|
||||
router.use(
|
||||
'/sso',
|
||||
'/oauth',
|
||||
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
|
||||
);
|
||||
|
||||
router.use(grant.express()(getGrantConfig()));
|
||||
router.get('/oauth/:provider', asyncHandler(async(req, res, next) => {
|
||||
const config = { ...grantConfig };
|
||||
delete config.defaults;
|
||||
|
||||
const availableProviders = Object.keys(config);
|
||||
|
||||
if (availableProviders.includes(req.params.provider) === false) {
|
||||
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
|
||||
}
|
||||
|
||||
if (req.query?.redirect && req.session) {
|
||||
req.session.redirect = req.query.redirect;
|
||||
}
|
||||
|
||||
next();
|
||||
}));
|
||||
|
||||
router.use(grant.express()(grantConfig));
|
||||
|
||||
/**
|
||||
* @todo allow json / cookie mode in SSO
|
||||
*/
|
||||
router.get(
|
||||
'/sso/:provider/callback',
|
||||
'/oauth/:provider/callback',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const redirect = req.session?.redirect;
|
||||
|
||||
const accountability = {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
@@ -226,17 +249,29 @@ router.get(
|
||||
accountability: accountability,
|
||||
});
|
||||
|
||||
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response.profile);
|
||||
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response?.profile);
|
||||
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate(
|
||||
email
|
||||
);
|
||||
req.session?.destroy(() => { });
|
||||
|
||||
res.locals.payload = {
|
||||
data: { access_token: accessToken, refresh_token: refreshToken, expires },
|
||||
};
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ email });
|
||||
|
||||
return next();
|
||||
if (redirect) {
|
||||
res.cookie('directus_refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
|
||||
secure: env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : false,
|
||||
sameSite:
|
||||
(env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
|
||||
});
|
||||
|
||||
return res.redirect(redirect);
|
||||
} else {
|
||||
res.locals.payload = {
|
||||
data: { access_token: accessToken, refresh_token: refreshToken, expires },
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ router.post(
|
||||
try {
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
res.locals.payload = {
|
||||
data: res.locals.savedFiles.length === 1 ? record[0] : record || null,
|
||||
data: res.locals.savedFiles.length === 1 ? record![0] : record || null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) {
|
||||
|
||||
@@ -1,207 +1,233 @@
|
||||
import { AST, NestedCollectionAST } from '../types/ast';
|
||||
import { clone, uniq, pick } from 'lodash';
|
||||
import { clone, cloneDeep, uniq, pick } from 'lodash';
|
||||
import database from './index';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import { Query, Item } from '../types';
|
||||
import { PayloadService } from '../services/payload';
|
||||
import applyQuery from '../utils/apply-query';
|
||||
import Knex from 'knex';
|
||||
import Knex, { QueryBuilder } from 'knex';
|
||||
|
||||
type RunASTOptions = {
|
||||
query?: AST['query'];
|
||||
knex?: Knex;
|
||||
child?: boolean;
|
||||
};
|
||||
|
||||
export default async function runAST(ast: AST, options?: RunASTOptions) {
|
||||
export default async function runAST(originalAST: AST, options?: RunASTOptions): Promise<null | Item | Item[]> {
|
||||
const ast = cloneDeep(originalAST);
|
||||
|
||||
const query = options?.query || ast.query;
|
||||
const knex = options?.knex || database;
|
||||
|
||||
// Retrieve the database columns to select in the current AST
|
||||
const { columnsToSelect, primaryKeyField, nestedCollectionASTs } = await parseCurrentLevel(ast, knex);
|
||||
|
||||
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
||||
const dbQuery = await getDBQuery(knex, ast.name, columnsToSelect, query, primaryKeyField);
|
||||
|
||||
const rawItems: Item | Item[] = await dbQuery;
|
||||
|
||||
if (!rawItems) return null;
|
||||
|
||||
// Run the items through the special transforms
|
||||
const payloadService = new PayloadService(ast.name, { knex });
|
||||
let items = await payloadService.processValues('read', rawItems);
|
||||
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
// Apply the `_in` filters to the nested collection batches
|
||||
const nestedASTs = applyParentFilters(nestedCollectionASTs, items);
|
||||
|
||||
for (const nestedAST of nestedASTs) {
|
||||
let tempLimit: number | null = null;
|
||||
|
||||
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
|
||||
// all nested items for all parent items at once. Because of this, we can't limit that query
|
||||
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
|
||||
// that there's _n_ items, which are then divided on the parent items. (no good)
|
||||
if (isO2M(nestedAST) && typeof nestedAST.query.limit === 'number') {
|
||||
tempLimit = nestedAST.query.limit;
|
||||
nestedAST.query.limit = -1;
|
||||
}
|
||||
|
||||
let nestedItems = await runAST(nestedAST, { knex, child: true });
|
||||
|
||||
if (nestedItems) {
|
||||
// Merge all fetched nested records with the parent items
|
||||
items = mergeWithParentItems(nestedItems, items, nestedAST, tempLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// During the fetching of data, we have to inject a couple of required fields for the child nesting
|
||||
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
|
||||
// and nesting is done, we parse through the output structure, and filter out all non-requested
|
||||
// fields
|
||||
if (options?.child !== true) {
|
||||
items = removeTemporaryFields(items, originalAST);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function parseCurrentLevel(ast: AST, knex: Knex) {
|
||||
const schemaInspector = SchemaInspector(knex);
|
||||
|
||||
const toplevelFields: string[] = [];
|
||||
const tempFields: string[] = [];
|
||||
const nestedCollections: NestedCollectionAST[] = [];
|
||||
const primaryKeyField = await schemaInspector.primary(ast.name);
|
||||
|
||||
const columnsInCollection = (await schemaInspector.columns(ast.name)).map(
|
||||
({ column }) => column
|
||||
);
|
||||
|
||||
const payloadService = new PayloadService(ast.name, { knex });
|
||||
const columnsToSelect: string[] = [];
|
||||
const nestedCollectionASTs: NestedCollectionAST[] = [];
|
||||
|
||||
for (const child of ast.children) {
|
||||
if (child.type === 'field') {
|
||||
if (columnsInCollection.includes(child.name) || child.name === '*') {
|
||||
toplevelFields.push(child.name);
|
||||
columnsToSelect.push(child.name);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!child.relation) continue;
|
||||
|
||||
const m2o = isM2O(child);
|
||||
|
||||
if (m2o) {
|
||||
toplevelFields.push(child.relation.many_field);
|
||||
columnsToSelect.push(child.relation.many_field);
|
||||
}
|
||||
|
||||
nestedCollections.push(child);
|
||||
nestedCollectionASTs.push(child);
|
||||
}
|
||||
|
||||
/** Always fetch primary key in case there's a nested relation that needs it */
|
||||
if (toplevelFields.includes(primaryKeyField) === false) {
|
||||
tempFields.push(primaryKeyField);
|
||||
if (columnsToSelect.includes(primaryKeyField) === false) {
|
||||
columnsToSelect.push(primaryKeyField);
|
||||
}
|
||||
|
||||
let dbQuery = knex.select([...toplevelFields, ...tempFields]).from(ast.name);
|
||||
return { columnsToSelect, nestedCollectionASTs, primaryKeyField };
|
||||
}
|
||||
|
||||
// Query defaults
|
||||
query.limit = typeof query.limit === 'number' ? query.limit : 100;
|
||||
async function getDBQuery(knex: Knex, table: string, columns: string[], query: Query, primaryKeyField: string): Promise<QueryBuilder> {
|
||||
let dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
|
||||
|
||||
if (query.limit === -1) {
|
||||
delete query.limit;
|
||||
const queryCopy = clone(query);
|
||||
|
||||
queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : 100;
|
||||
|
||||
if (queryCopy.limit === -1) {
|
||||
delete queryCopy.limit;
|
||||
}
|
||||
|
||||
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
|
||||
|
||||
await applyQuery(ast.name, dbQuery, query);
|
||||
await applyQuery(table, dbQuery, queryCopy);
|
||||
|
||||
let results: Item[] = await dbQuery;
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
results = await payloadService.processValues('read', results);
|
||||
function applyParentFilters(nestedCollectionASTs: NestedCollectionAST[], parentItem: Item | Item[]) {
|
||||
const parentItems = Array.isArray(parentItem) ? parentItem : [parentItem];
|
||||
|
||||
for (const batch of nestedCollections) {
|
||||
const m2o = isM2O(batch);
|
||||
for (const nestedAST of nestedCollectionASTs) {
|
||||
if (!nestedAST.relation) continue;
|
||||
|
||||
let batchQuery: Query = {};
|
||||
let tempField: string;
|
||||
let tempLimit: number;
|
||||
|
||||
if (m2o) {
|
||||
// Make sure we always fetch the nested items primary key field to ensure we have the key to match the item by
|
||||
const toplevelFields = batch.children
|
||||
.filter(({ type }) => type === 'field')
|
||||
.map(({ name }) => name);
|
||||
if (
|
||||
toplevelFields.includes(batch.relation.one_primary) === false &&
|
||||
toplevelFields.includes('*') === false
|
||||
) {
|
||||
tempField = batch.relation.one_primary;
|
||||
batch.children.push({ type: 'field', name: batch.relation.one_primary });
|
||||
}
|
||||
|
||||
batchQuery = {
|
||||
...batch.query,
|
||||
if (isM2O(nestedAST)) {
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
filter: {
|
||||
...(batch.query.filter || {}),
|
||||
[batch.relation.one_primary]: {
|
||||
_in: uniq(results.map((res) => res[batch.relation.many_field])).filter(
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.one_primary]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedAST.relation.many_field])).filter(
|
||||
(id) => id
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// o2m
|
||||
// Make sure we always fetch the related m2o field to ensure we have the foreign key to
|
||||
// match the items by
|
||||
const toplevelFields = batch.children
|
||||
.filter(({ type }) => type === 'field')
|
||||
.map(({ name }) => name);
|
||||
if (
|
||||
toplevelFields.includes(batch.relation.many_field) === false &&
|
||||
toplevelFields.includes('*') === false
|
||||
) {
|
||||
tempField = batch.relation.many_field;
|
||||
batch.children.push({ type: 'field', name: batch.relation.many_field });
|
||||
const relatedM2OisFetched = !!nestedAST.children.find((child) => {
|
||||
return child.type === 'field' && child.name === nestedAST.relation.many_field
|
||||
});
|
||||
|
||||
if (relatedM2OisFetched === false) {
|
||||
nestedAST.children.push({ type: 'field', name: nestedAST.relation.many_field });
|
||||
}
|
||||
|
||||
batchQuery = {
|
||||
...batch.query,
|
||||
nestedAST.query = {
|
||||
...nestedAST.query,
|
||||
filter: {
|
||||
...(batch.query.filter || {}),
|
||||
[batch.relation.many_field]: {
|
||||
_in: uniq(results.map((res) => res[batch.parentKey])).filter((id) => id),
|
||||
},
|
||||
},
|
||||
};
|
||||
...(nestedAST.query.filter || {}),
|
||||
[nestedAST.relation.many_field]: {
|
||||
_in: uniq(parentItems.map((res) => res[nestedAST.parentKey])).filter((id) => id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nested queries are done with a WHERE m2o IN (pk, pk, pk) query. We have to remove
|
||||
* LIMIT from that equation to ensure we limit `n` items _per parent record_ instead of
|
||||
* `n` items in total. This limit will then be re-applied in the stitching process
|
||||
* down below
|
||||
*/
|
||||
if (typeof batchQuery.limit === 'number') {
|
||||
tempLimit = batchQuery.limit;
|
||||
batchQuery.limit = -1;
|
||||
return nestedCollectionASTs;
|
||||
}
|
||||
|
||||
function mergeWithParentItems(nestedItem: Item | Item[], parentItem: Item | Item[], nestedAST: NestedCollectionAST, o2mLimit?: number | null) {
|
||||
const nestedItems = Array.isArray(nestedItem) ? nestedItem : [nestedItem];
|
||||
const parentItems = clone(Array.isArray(parentItem) ? parentItem : [parentItem]);
|
||||
|
||||
if (isM2O(nestedAST)) {
|
||||
for (const parentItem of parentItems) {
|
||||
const itemChild = nestedItems.find((nestedItem) => {
|
||||
return nestedItem[nestedAST.relation.one_primary] === parentItem[nestedAST.fieldKey];
|
||||
});
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChild || null;
|
||||
}
|
||||
} else {
|
||||
for (const parentItem of parentItems) {
|
||||
let itemChildren = nestedItems.filter((nestedItem) => {
|
||||
if (nestedItem === null) return false;
|
||||
if (Array.isArray(nestedItem[nestedAST.relation.many_field])) return true;
|
||||
|
||||
return (
|
||||
nestedItem[nestedAST.relation.many_field] === parentItem[nestedAST.relation.one_primary] ||
|
||||
nestedItem[nestedAST.relation.many_field]?.[nestedAST.relation.many_primary] === parentItem[nestedAST.relation.one_primary]
|
||||
);
|
||||
});
|
||||
|
||||
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
|
||||
if (o2mLimit !== null) {
|
||||
itemChildren = itemChildren.slice(0, o2mLimit);
|
||||
nestedAST.query.limit = o2mLimit;
|
||||
}
|
||||
|
||||
parentItem[nestedAST.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(parentItem) ? parentItems : parentItems[0];
|
||||
}
|
||||
|
||||
function removeTemporaryFields(rawItem: Item | Item[], ast: AST | NestedCollectionAST): Item | Item[] {
|
||||
const rawItems: Item[] = Array.isArray(rawItem) ? rawItem : [rawItem];
|
||||
|
||||
const items: Item[] = [];
|
||||
|
||||
const fields = ast.children.filter((child) => child.type === 'field').map((child) => child.name);
|
||||
const nestedCollections = ast.children.filter((child) => child.type === 'collection') as NestedCollectionAST[];
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null) return rawItem;
|
||||
const item = fields.includes('*') ? rawItem : pick(rawItem, fields);
|
||||
|
||||
for (const nestedCollection of nestedCollections) {
|
||||
if (item[nestedCollection.fieldKey] !== null) {
|
||||
item[nestedCollection.fieldKey] = removeTemporaryFields(rawItem[nestedCollection.fieldKey], nestedCollection);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedResults = await runAST(batch, { query: batchQuery, knex });
|
||||
|
||||
results = results.map((record) => {
|
||||
if (m2o) {
|
||||
const nestedResult =
|
||||
clone(
|
||||
nestedResults.find((nestedRecord) => {
|
||||
return (
|
||||
nestedRecord[batch.relation.one_primary] === record[batch.fieldKey]
|
||||
);
|
||||
})
|
||||
) || null;
|
||||
|
||||
if (tempField && nestedResult) {
|
||||
delete nestedResult[tempField];
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
[batch.fieldKey]: nestedResult,
|
||||
};
|
||||
}
|
||||
|
||||
// o2m
|
||||
let resultsForCurrentRecord = nestedResults
|
||||
.filter((nestedRecord) => {
|
||||
return (
|
||||
nestedRecord[batch.relation.many_field] ===
|
||||
record[batch.relation.one_primary] ||
|
||||
// In case of nested object:
|
||||
nestedRecord[batch.relation.many_field]?.[batch.relation.many_primary] ===
|
||||
record[batch.relation.one_primary]
|
||||
);
|
||||
})
|
||||
.map((nestedRecord) => {
|
||||
if (tempField) {
|
||||
delete nestedRecord[tempField];
|
||||
}
|
||||
|
||||
return nestedRecord;
|
||||
});
|
||||
|
||||
// Reapply LIMIT query on a per-record basis
|
||||
if (typeof tempLimit === 'number') {
|
||||
resultsForCurrentRecord = resultsForCurrentRecord.slice(0, tempLimit);
|
||||
}
|
||||
|
||||
const newRecord = {
|
||||
...record,
|
||||
[batch.fieldKey]: resultsForCurrentRecord,
|
||||
};
|
||||
|
||||
return newRecord;
|
||||
});
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
const nestedCollectionKeys = nestedCollections.map(({ fieldKey }) => fieldKey);
|
||||
|
||||
if (toplevelFields.includes('*')) {
|
||||
return results;
|
||||
}
|
||||
|
||||
return results.map((result) =>
|
||||
pick(result, uniq([...nestedCollectionKeys, ...toplevelFields]))
|
||||
);
|
||||
return Array.isArray(rawItem) ? items : items[0];
|
||||
}
|
||||
|
||||
function isM2O(child: NestedCollectionAST) {
|
||||
@@ -209,3 +235,7 @@ function isM2O(child: NestedCollectionAST) {
|
||||
child.relation.one_collection === child.name && child.relation.many_field === child.fieldKey
|
||||
);
|
||||
}
|
||||
|
||||
function isO2M(child: NestedCollectionAST) {
|
||||
return isM2O(child) === false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ columns:
|
||||
type: string
|
||||
length: 128
|
||||
nullable: false
|
||||
unique: true
|
||||
password:
|
||||
type: string
|
||||
length: 255
|
||||
@@ -58,7 +59,7 @@ columns:
|
||||
token:
|
||||
type: string
|
||||
length: 255
|
||||
last_login:
|
||||
last_access:
|
||||
type: timestamp
|
||||
last_page:
|
||||
type: string
|
||||
|
||||
@@ -3,6 +3,6 @@ import { EventEmitter2 } from 'eventemitter2';
|
||||
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true, delimiter: '.' });
|
||||
|
||||
// No-op function to ensure we never end up with no data
|
||||
emitter.on('item.*.*.before', (input) => input);
|
||||
emitter.on('*.*.before', input => input);
|
||||
|
||||
export default emitter;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FilterOperator } from '../types';
|
||||
|
||||
type FailedValidationExtensions = {
|
||||
field: string;
|
||||
type: FilterOperator;
|
||||
type: FilterOperator | 'required';
|
||||
valid?: number | string | (number | string)[];
|
||||
invalid?: number | string | (number | string)[];
|
||||
substring?: string;
|
||||
@@ -16,8 +16,6 @@ export class FailedValidationException extends BaseException {
|
||||
field: error.path[0] as string,
|
||||
};
|
||||
|
||||
console.log(error);
|
||||
|
||||
const joiType = error.type;
|
||||
|
||||
// eq | in | null | empty
|
||||
@@ -94,6 +92,11 @@ export class FailedValidationException extends BaseException {
|
||||
extensions.substring = error.context?.substring;
|
||||
}
|
||||
|
||||
// required
|
||||
if (joiType.endsWith('required')) {
|
||||
extensions.type = 'required';
|
||||
}
|
||||
|
||||
super(error.message, 400, 'FAILED_VALIDATION', extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class ItemNotFoundException extends BaseException {
|
||||
constructor(id: string | number, collection: string) {
|
||||
constructor(id: string | number | (string | number)[], collection: string) {
|
||||
super(`Item "${id}" doesn't exist in "${collection}".`, 404, 'ITEM_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
39
api/src/grant.ts
Normal file
39
api/src/grant.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Grant is the oAuth library
|
||||
*/
|
||||
|
||||
import env from './env';
|
||||
|
||||
const enabledProviders = (env.OAUTH_PROVIDERS as string)
|
||||
.split(',')
|
||||
.map((provider) => provider.trim().toLowerCase());
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
origin: env.PUBLIC_URL,
|
||||
transport: 'session',
|
||||
prefix: '/auth/oauth',
|
||||
response: ['tokens', 'profile'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.startsWith('OAUTH') === false) continue;
|
||||
|
||||
const parts = key.split('_');
|
||||
const provider = parts[1].toLowerCase();
|
||||
|
||||
if (enabledProviders.includes(provider) === false) continue;
|
||||
|
||||
// OAUTH <PROVIDER> SETTING = VALUE
|
||||
parts.splice(0, 2);
|
||||
|
||||
const configKey = parts.join('_').toLowerCase();
|
||||
|
||||
config[provider] = {
|
||||
...(config[provider] || {}),
|
||||
[configKey]: value,
|
||||
};
|
||||
}
|
||||
|
||||
export default config;
|
||||
@@ -49,13 +49,9 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
/** @TODO verify user status */
|
||||
|
||||
req.accountability.user = payload.id;
|
||||
req.accountability.role = user.role;
|
||||
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
||||
|
||||
return next();
|
||||
} else {
|
||||
// Try finding the user with the provided token
|
||||
const user = await database
|
||||
@@ -77,13 +73,10 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @TODO
|
||||
* Implement static tokens
|
||||
*
|
||||
* @NOTE
|
||||
* We'll silently ignore wrong tokens. This makes sure we prevent brute-forcing static tokens
|
||||
*/
|
||||
if (req.accountability?.user) {
|
||||
await database('directus_users').update({ last_access: new Date() }).where({ id: req.accountability.user });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,62 +12,84 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
|
||||
req.sanitizedQuery = {};
|
||||
if (!req.query) return;
|
||||
|
||||
const query: Query = {
|
||||
fields: sanitizeFields(req.query.fields) || ['*'],
|
||||
};
|
||||
req.sanitizedQuery = sanitize(
|
||||
{
|
||||
fields: req.query.fields || '*',
|
||||
...req.query
|
||||
},
|
||||
req.accountability || null
|
||||
);
|
||||
|
||||
if (req.query.limit !== undefined) {
|
||||
const limit = sanitizeLimit(req.query.limit);
|
||||
Object.freeze(req.sanitizedQuery);
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
function sanitize(rawQuery: Record<string, any>, accountability: Accountability | null) {
|
||||
const query: Query = {};
|
||||
|
||||
if (rawQuery.limit !== undefined) {
|
||||
const limit = sanitizeLimit(rawQuery.limit);
|
||||
|
||||
if (typeof limit === 'number') {
|
||||
query.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.sort) {
|
||||
query.sort = sanitizeSort(req.query.sort);
|
||||
if (rawQuery.fields) {
|
||||
query.fields = sanitizeFields(rawQuery.fields);
|
||||
}
|
||||
|
||||
if (req.query.filter) {
|
||||
query.filter = sanitizeFilter(req.query.filter, req.accountability || null);
|
||||
if (rawQuery.sort) {
|
||||
query.sort = sanitizeSort(rawQuery.sort);
|
||||
}
|
||||
|
||||
if (req.query.limit == '-1') {
|
||||
if (rawQuery.filter) {
|
||||
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
|
||||
}
|
||||
|
||||
if (rawQuery.limit == '-1') {
|
||||
delete query.limit;
|
||||
}
|
||||
|
||||
if (req.query.offset) {
|
||||
query.offset = sanitizeOffset(req.query.offset);
|
||||
if (rawQuery.offset) {
|
||||
query.offset = sanitizeOffset(rawQuery.offset);
|
||||
}
|
||||
|
||||
if (req.query.page) {
|
||||
query.page = sanitizePage(req.query.page);
|
||||
if (rawQuery.page) {
|
||||
query.page = sanitizePage(rawQuery.page);
|
||||
}
|
||||
|
||||
if (req.query.single) {
|
||||
query.single = sanitizeSingle(req.query.single);
|
||||
if (rawQuery.single) {
|
||||
query.single = sanitizeSingle(rawQuery.single);
|
||||
}
|
||||
|
||||
if (req.query.meta) {
|
||||
query.meta = sanitizeMeta(req.query.meta);
|
||||
if (rawQuery.meta) {
|
||||
query.meta = sanitizeMeta(rawQuery.meta);
|
||||
}
|
||||
|
||||
if (req.query.search && typeof req.query.search === 'string') {
|
||||
query.search = req.query.search;
|
||||
if (rawQuery.search && typeof rawQuery.search === 'string') {
|
||||
query.search = rawQuery.search;
|
||||
}
|
||||
|
||||
if (
|
||||
req.query.export &&
|
||||
typeof req.query.export === 'string' &&
|
||||
['json', 'csv'].includes(req.query.export)
|
||||
rawQuery.export &&
|
||||
typeof rawQuery.export === 'string' &&
|
||||
['json', 'csv'].includes(rawQuery.export)
|
||||
) {
|
||||
query.export = req.query.export as 'json' | 'csv';
|
||||
query.export = rawQuery.export as 'json' | 'csv';
|
||||
}
|
||||
|
||||
req.sanitizedQuery = query;
|
||||
Object.freeze(req.sanitizedQuery);
|
||||
return next();
|
||||
};
|
||||
if (rawQuery.deep as Record<string, any>) {
|
||||
if (!query.deep) query.deep = {};
|
||||
|
||||
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
|
||||
query.deep[field] = sanitize(deepRawQuery as any, accountability);
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export default sanitizeQuery;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Item,
|
||||
PrimaryKey,
|
||||
} from '../types';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import Knex from 'knex';
|
||||
import { ForbiddenException, FailedValidationException } from '../exceptions';
|
||||
import { uniq, merge } from 'lodash';
|
||||
@@ -139,12 +140,17 @@ export class AuthorizationService {
|
||||
|
||||
const parsedPermissions = parseFilter(permissions.permissions, accountability);
|
||||
|
||||
ast.query = {
|
||||
...ast.query,
|
||||
filter: {
|
||||
_and: [ast.query.filter || {}, parsedPermissions],
|
||||
},
|
||||
};
|
||||
if (!ast.query.filter || Object.keys(ast.query.filter).length === 0) {
|
||||
ast.query.filter = { _and: [] };
|
||||
} else {
|
||||
ast.query.filter = { _and: [ast.query.filter] };
|
||||
}
|
||||
|
||||
if (parsedPermissions && Object.keys(parsedPermissions).length > 0) {
|
||||
ast.query.filter._and.push(parsedPermissions);
|
||||
}
|
||||
|
||||
if (ast.query.filter._and.length === 0) delete ast.query.filter._and;
|
||||
|
||||
if (permissions.limit && ast.query.limit && ast.query.limit > permissions.limit) {
|
||||
throw new ForbiddenException(
|
||||
@@ -185,29 +191,39 @@ export class AuthorizationService {
|
||||
collection: string,
|
||||
payload: Partial<Item>[] | Partial<Item>
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
const validationErrors: FailedValidationException[] = [];
|
||||
|
||||
let payloads = Array.isArray(payload) ? payload : [payload];
|
||||
|
||||
const permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
let permission: Permission | undefined;
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
if (this.accountability?.admin === true) {
|
||||
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
// Check if you have permission to access the fields you're trying to acces
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
if (!permission) throw new ForbiddenException();
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,16 +232,37 @@ export class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const schema = generateJoi(permission.validation);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const columns = await schemaInspector.columnInfo(collection);
|
||||
const requiredColumns = columns.filter((column) => column.is_nullable === false && column.has_auto_increment === false && column.default_value === null);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
if (requiredColumns.length > 0) {
|
||||
permission.validation = {
|
||||
_and: [
|
||||
permission.validation,
|
||||
{}
|
||||
]
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error.details.map((details) => new FailedValidationException(details));
|
||||
if (action === 'create') {
|
||||
for (const { name } of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_required: true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const { name } of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_nnull: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationErrors.push(...this.validateJoi(permission.validation, payloads));
|
||||
|
||||
if (validationErrors.length > 0) throw validationErrors;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payloads;
|
||||
} else {
|
||||
@@ -233,11 +270,49 @@ export class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
|
||||
const errors: FailedValidationException[] = [];
|
||||
|
||||
/**
|
||||
* Note there can only be a single _and / _or per level
|
||||
*/
|
||||
|
||||
if (Object.keys(validation)[0] === '_and') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat().filter((err?: FailedValidationException) => err);
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
|
||||
if (Object.keys(validation)[0] === '_or') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat();
|
||||
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
|
||||
|
||||
if (allErrored) {
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
}
|
||||
|
||||
const schema = generateJoi(validation);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(...error.details.map((details) => new FailedValidationException(details)));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async checkAccess(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
pk: PrimaryKey | PrimaryKey[]
|
||||
) {
|
||||
if (this.accountability?.admin === true) return;
|
||||
|
||||
const itemsService = new ItemsService(collection, { accountability: this.accountability });
|
||||
|
||||
try {
|
||||
|
||||
@@ -60,6 +60,10 @@ export class CollectionsService {
|
||||
throw new InvalidPayloadException(`The "collection" key is required.`);
|
||||
}
|
||||
|
||||
if (payload.collection.startsWith('directus_')) {
|
||||
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
|
||||
}
|
||||
|
||||
if (await schemaInspector.hasTable(payload.collection)) {
|
||||
throw new InvalidPayloadException(
|
||||
`Collection "${payload.collection}" already exists.`
|
||||
@@ -128,16 +132,16 @@ export class CollectionsService {
|
||||
|
||||
const tablesInDatabase = await schemaInspector.tableInfo();
|
||||
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
|
||||
const meta: any[] = await collectionItemsService.readByQuery({
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: collectionKeys } },
|
||||
});
|
||||
}) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
const collection: Collection = {
|
||||
collection: table.name,
|
||||
meta: meta.find((systemInfo) => systemInfo.collection === table.name) || null,
|
||||
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
|
||||
schema: table,
|
||||
};
|
||||
|
||||
@@ -166,16 +170,16 @@ export class CollectionsService {
|
||||
}
|
||||
|
||||
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
|
||||
const meta: any[] = await collectionItemsService.readByQuery({
|
||||
const meta = await collectionItemsService.readByQuery({
|
||||
filter: { collection: { _in: tablesToFetchInfoFor } },
|
||||
});
|
||||
}) as Collection['meta'][];
|
||||
|
||||
const collections: Collection[] = [];
|
||||
|
||||
for (const table of tablesInDatabase) {
|
||||
const collection: Collection = {
|
||||
collection: table.name,
|
||||
meta: meta.find((systemInfo) => systemInfo.collection === table.name) || null,
|
||||
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
|
||||
schema: table,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,36 +5,31 @@ import { ItemsService } from '../services/items';
|
||||
import { ColumnBuilder } from 'knex';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { types } from '../types';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
|
||||
import Knex, { CreateTableBuilder } from 'knex';
|
||||
import { PayloadService } from '../services/payload';
|
||||
import getDefaultValue from '../utils/get-default-value';
|
||||
import cache from '../cache';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
|
||||
type RawField = Partial<Field> & { field: string; type: typeof types[number] };
|
||||
|
||||
/**
|
||||
* @todo
|
||||
*
|
||||
* - Only allow admins to create/update/delete
|
||||
* - Only return fields you have permission to read (based on permissions)
|
||||
* - Don't use items service, as this is a different case than regular collections
|
||||
*/
|
||||
|
||||
export class FieldsService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
itemsService: ItemsService;
|
||||
payloadService: PayloadService;
|
||||
schemaInspector: typeof schemaInspector;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.knex = options?.knex || database;
|
||||
this.schemaInspector = options?.knex ? SchemaInspector(options.knex) : schemaInspector;
|
||||
this.accountability = options?.accountability || null;
|
||||
this.itemsService = new ItemsService('directus_fields', options);
|
||||
this.payloadService = new PayloadService('directus_fields');
|
||||
}
|
||||
|
||||
async readAll(collection?: string) {
|
||||
async readAll(collection?: string): Promise<Field[]> {
|
||||
let fields: FieldMeta[];
|
||||
const nonAuthorizedItemsService = new ItemsService('directus_fields', { knex: this.knex });
|
||||
|
||||
@@ -73,9 +68,8 @@ export class FieldsService {
|
||||
});
|
||||
|
||||
const aliasQuery = this.knex
|
||||
.select<FieldMeta[]>('*')
|
||||
.from('directus_fields')
|
||||
.whereIn('special', ['alias', 'o2m', 'm2m']);
|
||||
.select<any[]>('*')
|
||||
.from('directus_fields');
|
||||
|
||||
if (collection) {
|
||||
aliasQuery.andWhere('collection', collection);
|
||||
@@ -83,6 +77,18 @@ export class FieldsService {
|
||||
|
||||
let aliasFields = await aliasQuery;
|
||||
|
||||
const aliasTypes = ['alias', 'o2m', 'm2m', 'files', 'files', 'translations'];
|
||||
|
||||
aliasFields = aliasFields.filter((field) => {
|
||||
const specials = (field.special || '').split(',');
|
||||
|
||||
for (const type of aliasTypes) {
|
||||
if (specials.includes(type)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
aliasFields = (await this.payloadService.processValues('read', aliasFields)) as FieldMeta[];
|
||||
|
||||
const aliasFieldsAsField = aliasFields.map((field) => {
|
||||
@@ -184,10 +190,12 @@ export class FieldsService {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* Check if table / directus_fields row already exists
|
||||
*/
|
||||
// Check if field already exists, either as a column, or as a row in directus_fields
|
||||
if (await this.schemaInspector.hasColumn(collection, field.field)) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
} else if (!!await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
}
|
||||
|
||||
if (field.schema) {
|
||||
if (table) {
|
||||
@@ -216,7 +224,7 @@ export class FieldsService {
|
||||
|
||||
async updateField(collection: string, field: RawField) {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException('Only admins can perform this action.');
|
||||
throw new ForbiddenException('Only admins can perform this action');
|
||||
}
|
||||
|
||||
if (field.schema) {
|
||||
@@ -339,6 +347,8 @@ export class FieldsService {
|
||||
column = table[type](field.field /* precision, scale */);
|
||||
} else if (field.type === 'csv') {
|
||||
column = table.string(field.field);
|
||||
} else if (field.type === 'dateTime') {
|
||||
column = table.dateTime(field.field, { useTz: false });
|
||||
} else {
|
||||
column = table[field.type](field.field);
|
||||
}
|
||||
@@ -347,7 +357,7 @@ export class FieldsService {
|
||||
column.defaultTo(field.schema.default_value);
|
||||
}
|
||||
|
||||
if (field.schema.is_nullable !== undefined && field.schema.is_nullable === false) {
|
||||
if (field.schema?.is_nullable !== undefined && field.schema.is_nullable === false) {
|
||||
column.notNullable();
|
||||
} else {
|
||||
column.nullable();
|
||||
|
||||
@@ -8,6 +8,8 @@ import path from 'path';
|
||||
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
|
||||
import { clone } from 'lodash';
|
||||
import cache from '../cache';
|
||||
import notFound from '../controllers/not-found';
|
||||
import { ItemNotFoundException } from '../exceptions';
|
||||
|
||||
export class FilesService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
@@ -89,7 +91,13 @@ export class FilesService extends ItemsService {
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const files = await super.readByKey(keys, { fields: ['id', 'storage'] });
|
||||
let files = await super.readByKey(keys, { fields: ['id', 'storage'] });
|
||||
|
||||
if (!files) {
|
||||
throw new ItemNotFoundException(key, 'directus_files');
|
||||
}
|
||||
|
||||
files = Array.isArray(files) ? files : [files];
|
||||
|
||||
for (const file of files) {
|
||||
const disk = storage.disk(file.storage);
|
||||
|
||||
@@ -28,11 +28,13 @@ export class ItemsService implements AbstractService {
|
||||
collection: string;
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
eventScope: string;
|
||||
|
||||
constructor(collection: string, options?: AbstractServiceOptions) {
|
||||
this.collection = collection;
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
this.eventScope = this.collection.startsWith('directus_') ? this.collection.substring(9) : 'item';
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -52,24 +54,24 @@ export class ItemsService implements AbstractService {
|
||||
knex: trx,
|
||||
});
|
||||
|
||||
if (this.collection.startsWith('directus_') === false) {
|
||||
const customProcessed = await emitter.emitAsync(
|
||||
`item.create.${this.collection}.before`,
|
||||
payloads,
|
||||
{
|
||||
event: `item.create.${this.collection}.before`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: null,
|
||||
action: 'create',
|
||||
payload: payloads,
|
||||
}
|
||||
);
|
||||
const customProcessed = await emitter.emitAsync(
|
||||
`${this.eventScope}.create.before`,
|
||||
payloads,
|
||||
{
|
||||
event: `${this.eventScope}.create.before`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: null,
|
||||
action: 'create',
|
||||
payload: payloads,
|
||||
}
|
||||
);
|
||||
|
||||
if (customProcessed) {
|
||||
payloads = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
@@ -169,18 +171,16 @@ export class ItemsService implements AbstractService {
|
||||
await cache.clear();
|
||||
}
|
||||
|
||||
if (this.collection.startsWith('directus_') === false) {
|
||||
emitter
|
||||
.emitAsync(`item.create.${this.collection}`, {
|
||||
event: `item.create.${this.collection}`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: primaryKeys,
|
||||
action: 'create',
|
||||
payload: payloads,
|
||||
})
|
||||
.catch((err) => logger.warn(err));
|
||||
}
|
||||
emitter
|
||||
.emitAsync(`${this.eventScope}.create`, {
|
||||
event: `${this.eventScope}.create`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: primaryKeys,
|
||||
action: 'create',
|
||||
payload: payloads,
|
||||
})
|
||||
.catch((err) => logger.warn(err));
|
||||
|
||||
return primaryKeys;
|
||||
});
|
||||
@@ -188,10 +188,11 @@ export class ItemsService implements AbstractService {
|
||||
return Array.isArray(data) ? savedPrimaryKeys : savedPrimaryKeys[0];
|
||||
}
|
||||
|
||||
async readByQuery(query: Query): Promise<Item[]> {
|
||||
async readByQuery(query: Query): Promise<null | Item | Item[]> {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
let ast = await getASTFromQuery(this.collection, query, {
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
@@ -202,22 +203,25 @@ export class ItemsService implements AbstractService {
|
||||
}
|
||||
|
||||
const records = await runAST(ast);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<Item>;
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<null | Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Item>;
|
||||
async readByKey(
|
||||
key: PrimaryKey | PrimaryKey[],
|
||||
query: Query = {},
|
||||
action: PermissionsAction = 'read'
|
||||
): Promise<Item | Item[]> {
|
||||
): Promise<null | Item | Item[]> {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const primaryKeyField = await schemaInspector.primary(this.collection);
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
|
||||
if (keys.length === 1) {
|
||||
query.single = true;
|
||||
}
|
||||
|
||||
const queryWithFilter = {
|
||||
...query,
|
||||
filter: {
|
||||
@@ -242,8 +246,8 @@ export class ItemsService implements AbstractService {
|
||||
ast = await authorizationService.processAST(ast, action);
|
||||
}
|
||||
|
||||
const records = await runAST(ast, { knex: this.knex });
|
||||
return Array.isArray(key) ? records : records[0];
|
||||
const result = await runAST(ast, { knex: this.knex });
|
||||
return result;
|
||||
}
|
||||
|
||||
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
@@ -263,28 +267,30 @@ export class ItemsService implements AbstractService {
|
||||
|
||||
let payload = clone(data);
|
||||
|
||||
if (this.collection.startsWith('directus_') === false) {
|
||||
const customProcessed = await emitter.emitAsync(
|
||||
`item.update.${this.collection}.before`,
|
||||
const customProcessed = await emitter.emitAsync(
|
||||
`${this.eventScope}.update.before`,
|
||||
payload,
|
||||
{
|
||||
event: `${this.eventScope}.update.before`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: null,
|
||||
action: 'update',
|
||||
payload,
|
||||
{
|
||||
event: `item.update.${this.collection}.before`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: null,
|
||||
action: 'update',
|
||||
payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (customProcessed) {
|
||||
payload = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
|
||||
payload = await authorizationService.validatePayload(
|
||||
'update',
|
||||
this.collection,
|
||||
@@ -351,7 +357,7 @@ export class ItemsService implements AbstractService {
|
||||
activity: key,
|
||||
collection: this.collection,
|
||||
item: keys[index],
|
||||
data: JSON.stringify(snapshots[index]),
|
||||
data: JSON.stringify(snapshots?.[index]),
|
||||
delta: JSON.stringify(payloadWithoutAliases),
|
||||
}));
|
||||
|
||||
@@ -364,8 +370,8 @@ export class ItemsService implements AbstractService {
|
||||
}
|
||||
|
||||
emitter
|
||||
.emitAsync(`item.update.${this.collection}`, {
|
||||
event: `item.update.${this.collection}`,
|
||||
.emitAsync(`${this.eventScope}.update`, {
|
||||
event: `${this.eventScope}.update`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: key,
|
||||
@@ -419,8 +425,8 @@ export class ItemsService implements AbstractService {
|
||||
await authorizationService.checkAccess('delete', this.collection, key);
|
||||
}
|
||||
|
||||
await emitter.emitAsync(`item.delete.${this.collection}.before`, {
|
||||
event: `item.update.${this.collection}`,
|
||||
await emitter.emitAsync(`${this.eventScope}.delete.before`, {
|
||||
event: `${this.eventScope}.delete.before`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: keys,
|
||||
@@ -450,8 +456,8 @@ export class ItemsService implements AbstractService {
|
||||
}
|
||||
|
||||
emitter
|
||||
.emitAsync(`item.delete.${this.collection}`, {
|
||||
event: `item.delete.${this.collection}`,
|
||||
.emitAsync(`${this.eventScope}.delete`, {
|
||||
event: `${this.eventScope}.delete`,
|
||||
accountability: this.accountability,
|
||||
collection: this.collection,
|
||||
item: keys,
|
||||
@@ -466,10 +472,9 @@ export class ItemsService implements AbstractService {
|
||||
async readSingleton(query: Query) {
|
||||
query = clone(query);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
query.limit = 1;
|
||||
query.single = true;
|
||||
|
||||
const records = await this.readByQuery(query);
|
||||
const record = records[0];
|
||||
const record = await this.readByQuery(query) as Item;
|
||||
|
||||
if (!record) {
|
||||
const columns = await schemaInspector.columnInfo(this.collection);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class MetaService {
|
||||
const dbQuery = database(collection).count('*', { as: 'count' });
|
||||
|
||||
if (query.filter) {
|
||||
applyFilter(dbQuery, query.filter);
|
||||
applyFilter(dbQuery, query.filter, collection);
|
||||
}
|
||||
|
||||
const records = await dbQuery;
|
||||
|
||||
@@ -12,6 +12,9 @@ import { ItemsService } from './items';
|
||||
import { URL } from 'url';
|
||||
import Knex from 'knex';
|
||||
import env from '../env';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import getLocalType from '../utils/get-local-type';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
|
||||
type Action = 'create' | 'read' | 'update';
|
||||
|
||||
@@ -138,7 +141,7 @@ export class PayloadService {
|
||||
action: Action,
|
||||
payload: Partial<Item> | Partial<Item>[]
|
||||
): Promise<Partial<Item> | Partial<Item>[]> {
|
||||
const processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
|
||||
let processedPayload = (Array.isArray(payload) ? payload : [payload]) as Partial<Item>[];
|
||||
|
||||
if (processedPayload.length === 0) return [];
|
||||
|
||||
@@ -172,6 +175,10 @@ export class PayloadService {
|
||||
})
|
||||
);
|
||||
|
||||
if (action === 'read') {
|
||||
await this.processDates(processedPayload);
|
||||
}
|
||||
|
||||
if (['create', 'update'].includes(action)) {
|
||||
processedPayload.forEach((record) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
@@ -214,6 +221,51 @@ export class PayloadService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Knex returns `datetime` and `date` columns as Date.. This is wrong for date / datetime, as those
|
||||
* shouldn't return with time / timezone info respectively
|
||||
*/
|
||||
async processDates(payloads: Partial<Record<string, any>>[]) {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const columnsInCollection = await schemaInspector.columnInfo(this.collection);
|
||||
|
||||
const columnsWithType = columnsInCollection.map((column) => ({
|
||||
name: column.name,
|
||||
type: getLocalType(column.type),
|
||||
}));
|
||||
|
||||
const dateColumns = columnsWithType.filter((column) => ['dateTime', 'date', 'timestamp'].includes(column.type));
|
||||
|
||||
if (dateColumns.length === 0) return payloads;
|
||||
|
||||
for (const dateColumn of dateColumns) {
|
||||
for (const payload of payloads) {
|
||||
const value: Date = payload[dateColumn.name];
|
||||
|
||||
if (value) {
|
||||
if (dateColumn.type === 'timestamp') {
|
||||
const newValue = formatISO(value);
|
||||
payload[dateColumn.name] = newValue;
|
||||
}
|
||||
|
||||
if (dateColumn.type === 'dateTime') {
|
||||
// Strip off the Z at the end of a non-timezone datetime value
|
||||
const newValue = format(value, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
payload[dateColumn.name] = newValue;
|
||||
}
|
||||
|
||||
if (dateColumn.type === 'date') {
|
||||
// Strip off the time / timezone information from a date-only value
|
||||
const newValue = format(value, 'yyyy-MM-dd');
|
||||
payload[dateColumn.name] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively save/update all nested related m2o items
|
||||
*/
|
||||
@@ -294,11 +346,20 @@ export class PayloadService {
|
||||
});
|
||||
|
||||
for (const relation of relationsToProcess) {
|
||||
const relatedRecords: Partial<Item>[] = payload[relation.one_field].map(
|
||||
(record: Partial<Item>) => ({
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary],
|
||||
})
|
||||
const relatedRecords: Partial<Item>[] = payload[relation.one_field]
|
||||
.map(
|
||||
(record: string | number | Partial<Item>) => {
|
||||
if (typeof record === 'string' || typeof record === 'number') {
|
||||
record = {
|
||||
[relation.many_primary]: record
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary],
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const itemsService = new ItemsService(relation.many_collection, {
|
||||
|
||||
@@ -22,7 +22,7 @@ export class RolesService extends ItemsService {
|
||||
const permissionsForRole = await permissionsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
});
|
||||
}) as { id: number }[];
|
||||
const permissionIDs = permissionsForRole.map((permission) => permission.id);
|
||||
await permissionsService.delete(permissionIDs);
|
||||
|
||||
@@ -34,7 +34,7 @@ export class RolesService extends ItemsService {
|
||||
const presetsForRole = await presetsService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
});
|
||||
}) as { id: string }[];
|
||||
const presetIDs = presetsForRole.map((preset) => preset.id);
|
||||
await presetsService.delete(presetIDs);
|
||||
|
||||
@@ -46,7 +46,7 @@ export class RolesService extends ItemsService {
|
||||
const usersInRole = await usersService.readByQuery({
|
||||
fields: ['id'],
|
||||
filter: { role: { _in: keys } },
|
||||
});
|
||||
}) as { id: string }[];
|
||||
const userIDs = usersInRole.map((user) => user.id);
|
||||
await usersService.update({ status: 'suspended', role: null }, userIDs);
|
||||
|
||||
|
||||
@@ -5,6 +5,4 @@ export type Accountability = {
|
||||
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
|
||||
parent?: number;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,6 @@ export type Field = {
|
||||
collection: string;
|
||||
field: string;
|
||||
type: typeof types[number];
|
||||
schema: Column;
|
||||
schema: Column | null;
|
||||
meta: FieldMeta | null;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Query = {
|
||||
meta?: Meta[];
|
||||
search?: string;
|
||||
export?: 'json' | 'csv';
|
||||
deep?: Record<string, Query>;
|
||||
};
|
||||
|
||||
export type Sort = {
|
||||
|
||||
@@ -16,10 +16,10 @@ export interface AbstractService {
|
||||
create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
create(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
|
||||
readByQuery(query: Query): Promise<Item[]>;
|
||||
readByQuery(query: Query): Promise<null | Item | Item[]>;
|
||||
|
||||
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<Item[]>;
|
||||
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<Item>;
|
||||
readByKey(keys: PrimaryKey[], query: Query, action: PermissionsAction): Promise<null | Item[]>;
|
||||
readByKey(key: PrimaryKey, query: Query, action: PermissionsAction): Promise<null | Item>;
|
||||
|
||||
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { QueryBuilder } from 'knex';
|
||||
import { Query, Filter } from '../types';
|
||||
import { schemaInspector } from '../database';
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export default async function applyQuery(collection: string, dbQuery: QueryBuilder, query: Query) {
|
||||
if (query.filter) {
|
||||
applyFilter(dbQuery, query.filter);
|
||||
await applyFilter(dbQuery, query.filter, collection);
|
||||
}
|
||||
|
||||
if (query.sort) {
|
||||
@@ -45,8 +46,13 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
|
||||
}
|
||||
}
|
||||
|
||||
export function applyFilter(dbQuery: QueryBuilder, filter: Filter) {
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collection: string) {
|
||||
for (let [key, value] of Object.entries(filter)) {
|
||||
// Nested relational filter
|
||||
if (key.includes('.')) {
|
||||
key = await applyJoins(dbQuery, key, collection);
|
||||
}
|
||||
|
||||
if (key.startsWith('_') === false) {
|
||||
let operator = Object.keys(value)[0];
|
||||
|
||||
@@ -139,14 +145,55 @@ export function applyFilter(dbQuery: QueryBuilder, filter: Filter) {
|
||||
|
||||
if (key === '_or') {
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter));
|
||||
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
|
||||
});
|
||||
}
|
||||
|
||||
if (key === '_and') {
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter));
|
||||
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyJoins(dbQuery: QueryBuilder, path: string, collection: string) {
|
||||
const pathParts = path.split('.');
|
||||
|
||||
let keyName = '';
|
||||
|
||||
await addJoins(pathParts);
|
||||
|
||||
return keyName;
|
||||
|
||||
async function addJoins(pathParts: string[], parentCollection: string = collection) {
|
||||
const relation = await database
|
||||
.select('*')
|
||||
.from('directus_relations')
|
||||
.where({ one_collection: parentCollection, one_field: pathParts[0] })
|
||||
.orWhere({ many_collection: parentCollection, many_field: pathParts[0] })
|
||||
.first();
|
||||
|
||||
if (!relation) return;
|
||||
|
||||
const isM2O = relation.many_collection === parentCollection && relation.many_field === pathParts[0];
|
||||
|
||||
if (isM2O) {
|
||||
dbQuery.leftJoin(relation.one_collection, `${parentCollection}.${relation.many_field}`, `${relation.one_collection}.${relation.one_primary}`);
|
||||
} else {
|
||||
dbQuery.leftJoin(relation.many_collection, `${relation.one_collection}.${relation.one_primary}`, `${relation.many_collection}.${relation.many_field}`);
|
||||
}
|
||||
|
||||
pathParts.shift();
|
||||
|
||||
const parent = isM2O ? relation.one_collection : relation.many_collection;
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
keyName = `${parent}.${pathParts[0]}`;
|
||||
}
|
||||
|
||||
if (pathParts.length) {
|
||||
await addJoins(pathParts, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Filter } from '../types';
|
||||
import BaseJoi, { AnySchema } from 'joi';
|
||||
import BaseJoi, { AlternativesSchema, ObjectSchema, AnySchema } from 'joi';
|
||||
|
||||
const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
type: 'string',
|
||||
@@ -52,86 +52,97 @@ const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
},
|
||||
});
|
||||
|
||||
export default function generateJoi(filter: Filter | null) {
|
||||
export default function generateJoi(filter: Filter | null): AnySchema {
|
||||
filter = filter || {};
|
||||
|
||||
const schema: Record<string, AnySchema> = {};
|
||||
if (Object.keys(filter).length === 0) return Joi.any();
|
||||
|
||||
let schema: any;
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
const isField = key.startsWith('_') === false;
|
||||
if (key.startsWith('_') === false) {
|
||||
if (!schema) schema = {};
|
||||
|
||||
if (isField) {
|
||||
const operator = Object.keys(value)[0];
|
||||
const val = Object.keys(value)[1];
|
||||
|
||||
if (operator === '_eq') {
|
||||
schema[key] = Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
schema[key] = Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
schema[key] = Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
schema[key] = Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
schema[key] = Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
schema[key] = Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
schema[key] = getJoi(operator, val);
|
||||
}
|
||||
}
|
||||
|
||||
return Joi.object(schema).unknown();
|
||||
}
|
||||
|
||||
function getJoi(operator: string, value: any) {
|
||||
if (operator === '_eq') {
|
||||
return Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
return Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
return Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
return Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
return Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
return Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
return Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
return Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
return Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
return Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
return Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
return Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_required') {
|
||||
return Joi.invalid(null).required();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import database from '../database';
|
||||
import { clone } from 'lodash';
|
||||
import Knex from 'knex';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
|
||||
type GetASTOptions = {
|
||||
accountability?: Accountability | null;
|
||||
@@ -25,14 +26,13 @@ export default async function getASTFromQuery(
|
||||
collection: string,
|
||||
query: Query,
|
||||
options?: GetASTOptions
|
||||
// accountability?: Accountability | null,
|
||||
// action?: PermissionsAction
|
||||
): Promise<AST> {
|
||||
query = clone(query);
|
||||
|
||||
const accountability = options?.accountability;
|
||||
const action = options?.action || 'read';
|
||||
const knex = options?.knex || database;
|
||||
const schemaInspector = SchemaInspector(knex);
|
||||
|
||||
/**
|
||||
* we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every
|
||||
@@ -56,11 +56,13 @@ export default async function getASTFromQuery(
|
||||
};
|
||||
|
||||
const fields = query.fields || ['*'];
|
||||
const deep = query.deep || {};
|
||||
|
||||
// Prevent fields from showing up in the query object
|
||||
// Prevent fields/deep from showing up in the query object in further use
|
||||
delete query.fields;
|
||||
delete query.deep;
|
||||
|
||||
ast.children = parseFields(collection, fields).filter(filterEmptyChildCollections);
|
||||
ast.children = (await parseFields(collection, fields, deep)).filter(filterEmptyChildCollections);
|
||||
|
||||
return ast;
|
||||
|
||||
@@ -120,7 +122,7 @@ export default async function getASTFromQuery(
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseFields(parentCollection: string, fields: string[]) {
|
||||
async function parseFields(parentCollection: string, fields: string[], deep?: Record<string, Query>) {
|
||||
fields = convertWildcards(parentCollection, fields);
|
||||
|
||||
if (!fields) return [];
|
||||
@@ -157,10 +159,10 @@ export default async function getASTFromQuery(
|
||||
type: 'collection',
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: 'id' /** @todo this needs to come from somewhere real */,
|
||||
parentKey: await schemaInspector.primary(parentCollection),
|
||||
relation: relation,
|
||||
query: {} /** @todo inject nested query here: ?deep[foo]=bar */,
|
||||
children: parseFields(relatedCollection, nestedFields).filter(
|
||||
query: deep?.[relationalField] || {},
|
||||
children: (await parseFields(relatedCollection, nestedFields)).filter(
|
||||
filterEmptyChildCollections
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { get } from 'lodash';
|
||||
import env from '../env';
|
||||
import { ServiceUnavailableException } from '../exceptions';
|
||||
|
||||
// The path in JSON to fetch the email address from the profile.
|
||||
// Note: a lot of services use `email` as the path. We fall back to that as default, so no need to
|
||||
@@ -17,10 +18,11 @@ const profileMap: Record<string, string> = {};
|
||||
export default function getEmailFromProfile(provider: string, profile: Record<string, any>) {
|
||||
const path =
|
||||
profileMap[provider] || env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] || 'email';
|
||||
|
||||
const email = get(profile, path);
|
||||
|
||||
if (!email) {
|
||||
throw new Error("Couldn't extract email address from SSO provider response");
|
||||
throw new ServiceUnavailableException("Couldn't extract email address from SSO provider response", { service: 'oauth', provider });
|
||||
}
|
||||
|
||||
return email;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import env from '../env';
|
||||
|
||||
/**
|
||||
* Reads the environment variables to construct the configuration object required by Grant
|
||||
*/
|
||||
export default function getGrantConfig() {
|
||||
const enabledProviders = (env.OAUTH_PROVIDERS as string)
|
||||
.split(',')
|
||||
.map((provider) => provider.trim());
|
||||
|
||||
const config: any = {
|
||||
defaults: {
|
||||
origin: env.PUBLIC_URL,
|
||||
transport: 'session',
|
||||
prefix: '/auth/sso',
|
||||
response: ['tokens', 'profile'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key.startsWith('OAUTH') === false) continue;
|
||||
|
||||
const parts = key.split('_');
|
||||
const provider = parts[1].toLowerCase();
|
||||
|
||||
if (enabledProviders.includes(provider) === false) continue;
|
||||
|
||||
// OAUTH <PROVIDER> SETTING = VALUE
|
||||
parts.splice(0, 2);
|
||||
|
||||
const configKey = parts.join('_').toLowerCase();
|
||||
|
||||
config[provider] = {
|
||||
...(config[provider] || {}),
|
||||
[configKey]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
char: { type: 'string' },
|
||||
date: { type: 'date' },
|
||||
datetime: { type: 'dateTime' },
|
||||
dateTime: { type: 'dateTime' },
|
||||
timestamp: { type: 'timestamp' },
|
||||
time: { type: 'time' },
|
||||
float: { type: 'float' },
|
||||
@@ -70,7 +71,7 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
bpchar: { type: 'string' },
|
||||
timestamptz: { type: 'timestamp' },
|
||||
'timestamp with time zone': { type: 'timestamp', useTimezone: true },
|
||||
'timestamp without time zone': { type: 'timestamp' },
|
||||
'timestamp without time zone': { type: 'dateTime' },
|
||||
timetz: { type: 'time' },
|
||||
'time with time zone': { type: 'time', useTimezone: true },
|
||||
'time without time zone': { type: 'time' },
|
||||
|
||||
17
api/src/utils/test.ts
Normal file
17
api/src/utils/test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Joi from 'joi';
|
||||
|
||||
const schema = Joi.alternatives().try(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
age: Joi.number()
|
||||
}),
|
||||
Joi.string(),
|
||||
).match('all');
|
||||
|
||||
const value = {
|
||||
age: 25
|
||||
};
|
||||
|
||||
const { error } = schema.validate(value);
|
||||
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
@@ -7,3 +7,7 @@ charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{package.json,*.yml,*.yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
1747
app/package-lock.json
generated
1747
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-beta.1",
|
||||
"version": "9.0.0-beta.2",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -30,6 +30,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/docs": "^9.0.0-beta.2",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@popperjs/core": "^2.4.3",
|
||||
"@sindresorhus/slugify": "^1.0.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"csslint": "^1.0.5",
|
||||
"date-fns": "^2.14.0",
|
||||
"diff": "^4.0.2",
|
||||
"highlight.js": "^10.2.0",
|
||||
"htmlhint": "^0.14.1",
|
||||
"joi": "^17.2.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
@@ -94,6 +96,7 @@
|
||||
"@types/base-64": "^0.1.3",
|
||||
"@types/bytes": "^3.1.0",
|
||||
"@types/diff": "^4.0.2",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/jest": "^26.0.5",
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
@@ -126,6 +129,7 @@
|
||||
"lint-staged": "^10.2.11",
|
||||
"mockdate": "^3.0.2",
|
||||
"prettier": "^2.0.5",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-is": "^16.13.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ import VDivider from './v-divider';
|
||||
import VError from './v-error';
|
||||
import VFancySelect from './v-fancy-select';
|
||||
import VFieldTemplate from './v-field-template';
|
||||
import VFieldSelect from './v-field-select';
|
||||
import VForm from './v-form';
|
||||
import VHover from './v-hover/';
|
||||
import VIcon from './v-icon/';
|
||||
@@ -64,6 +65,7 @@ Vue.component('v-divider', VDivider);
|
||||
Vue.component('v-error', VError);
|
||||
Vue.component('v-fancy-select', VFancySelect);
|
||||
Vue.component('v-field-template', VFieldTemplate);
|
||||
Vue.component('v-field-select', VFieldSelect);
|
||||
Vue.component('v-form', VForm);
|
||||
Vue.component('v-hover', VHover);
|
||||
Vue.component('v-icon', VIcon);
|
||||
|
||||
@@ -75,8 +75,8 @@ export default defineComponent({
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-chip-color: var(--white);
|
||||
--v-chip-background-color: var(--primary);
|
||||
--v-chip-color: var(--black);
|
||||
--v-chip-background-color: var(--background-normal-alt);
|
||||
--v-chip-color-hover: var(--white);
|
||||
--v-chip-background-color-hover: var(--primary-125);
|
||||
--v-chip-close-color: var(--danger);
|
||||
@@ -89,7 +89,7 @@ body {
|
||||
.v-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
color: var(--v-chip-color);
|
||||
font-weight: var(--weight-normal);
|
||||
|
||||
4
app/src/components/v-field-select/index.ts
Normal file
4
app/src/components/v-field-select/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VFieldSelect from './v-field-select.vue';
|
||||
|
||||
export default VFieldSelect;
|
||||
export { VFieldSelect };
|
||||
1
app/src/components/v-field-select/readme.md
Normal file
1
app/src/components/v-field-select/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
# Field Select
|
||||
167
app/src/components/v-field-select/v-field-select.vue
Normal file
167
app/src/components/v-field-select/v-field-select.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<v-notice v-if="!availableFields || availableFields.length === 0">
|
||||
{{ $t('no_fields_in_collection', { collection: (collectionInfo && collectionInfo.name) || collection }) }}
|
||||
</v-notice>
|
||||
<draggable v-else v-model="selectedFields" draggable=".draggable" :set-data="hideDragImage" class="v-field-select">
|
||||
<v-chip
|
||||
v-for="(field, index) in selectedFields"
|
||||
:key="index"
|
||||
class="field draggable"
|
||||
v-tooltip="field.field"
|
||||
@click="removeField(field.field)"
|
||||
>
|
||||
{{ field.name }}
|
||||
</v-chip>
|
||||
|
||||
<template #footer>
|
||||
<v-menu show-arrow v-model="menuActive" class="add" placement="bottom">
|
||||
<template #activator="{ toggle }">
|
||||
<v-button @click="toggle" small>
|
||||
{{ $t('add_field') }}
|
||||
<v-icon small name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<field-list-item
|
||||
v-for="field in availableFields"
|
||||
:key="field.field"
|
||||
:field="field"
|
||||
:depth="depth"
|
||||
@add="addField"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted, PropType, computed } from '@vue/composition-api';
|
||||
import FieldListItem from '../v-field-template/field-list-item.vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Field } from '@/types/';
|
||||
import Draggable from 'vuedraggable';
|
||||
import useFieldTree from '@/composables/use-field-tree';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { FieldTree } from '../v-field-template/types';
|
||||
import hideDragImage from '@/utils/hide-drag-image';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldListItem, Draggable },
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: null,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const menuActive = ref(false);
|
||||
const { collection } = toRefs(props);
|
||||
|
||||
const { info, primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection);
|
||||
const { tree } = useFieldTree(collection, true);
|
||||
|
||||
const _value = computed({
|
||||
get() {
|
||||
return props.value || [];
|
||||
},
|
||||
set(newVal: string[]) {
|
||||
emit('input', newVal);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedFields = computed({
|
||||
get() {
|
||||
return _value.value.map((field) => ({
|
||||
field,
|
||||
name: findTree(tree.value, field.split('.'))?.name as string,
|
||||
}));
|
||||
},
|
||||
set(newVal: { field: string; name: string }[]) {
|
||||
_value.value = newVal.map((field) => field.field);
|
||||
},
|
||||
});
|
||||
|
||||
const availableFields = computed(() => {
|
||||
return filterTree(tree.value);
|
||||
});
|
||||
|
||||
return {
|
||||
menuActive,
|
||||
addField,
|
||||
removeField,
|
||||
availableFields,
|
||||
selectedFields,
|
||||
hideDragImage,
|
||||
tree,
|
||||
collectionInfo: info,
|
||||
};
|
||||
|
||||
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
|
||||
|
||||
if (fieldObject === undefined) return undefined;
|
||||
if (fieldSections.length === 1) return fieldObject;
|
||||
return findTree(fieldObject.children, fieldSections.slice(1));
|
||||
}
|
||||
|
||||
function filterTree(tree: FieldTree[] | undefined, prefix = '') {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const newTree: FieldTree[] = tree.map((field) => {
|
||||
return {
|
||||
name: field.name,
|
||||
field: field.field,
|
||||
disabled: _value.value.includes(prefix + field.field),
|
||||
children: filterTree(field.children, prefix + field.field + '.'),
|
||||
};
|
||||
});
|
||||
|
||||
return newTree.length === 0 ? undefined : newTree;
|
||||
}
|
||||
|
||||
function removeField(field: string) {
|
||||
_value.value = _value.value.filter((f) => f !== field);
|
||||
}
|
||||
|
||||
function addField(field: string) {
|
||||
const newArray = _value.value;
|
||||
newArray.push(field);
|
||||
_value.value = [...new Set(newArray)];
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-field-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.v-chip.field {
|
||||
margin-right: 5px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<v-list-item
|
||||
v-if="field.children === undefined"
|
||||
:disabled="field.disabled"
|
||||
@click="$emit('add', `${parent ? parent + '.' : ''}${field.field}`)"
|
||||
>
|
||||
<v-list-item-content>{{ field.name }}</v-list-item-content>
|
||||
@@ -12,6 +13,7 @@
|
||||
:key="childField.field"
|
||||
:parent="`${parent ? parent + '.' : ''}${field.field}`"
|
||||
:field="childField"
|
||||
:depth="depth - 1"
|
||||
@add="$emit('add', $event)"
|
||||
/>
|
||||
</v-list-group>
|
||||
@@ -32,6 +34,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Field } from '@/types';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string | TranslateResult;
|
||||
disabled?: boolean;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<field-list-item @add="addField" v-for="field in tree" :key="field.field" :field="field" />
|
||||
<field-list-item @add="addField" v-for="field in tree" :key="field.field" :field="field" :depth="depth" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
@@ -33,6 +33,7 @@ import FieldListItem from './field-list-item.vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Field } from '@/types/';
|
||||
import useFieldTree from '@/composables/use-field-tree';
|
||||
import { FieldTree } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldListItem },
|
||||
@@ -49,6 +50,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
@@ -149,7 +154,9 @@ export default defineComponent({
|
||||
|
||||
function addField(fieldKey: string) {
|
||||
if (!contentEl.value) return;
|
||||
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
|
||||
|
||||
const field = findTree(tree.value, fieldKey.split('.'));
|
||||
|
||||
if (!field) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
@@ -157,6 +164,12 @@ export default defineComponent({
|
||||
button.setAttribute('contenteditable', 'false');
|
||||
button.innerText = String(field.name);
|
||||
|
||||
if (window.getSelection()?.rangeCount == 0) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(contentEl.value.children[0]);
|
||||
window.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
range.deleteContents();
|
||||
@@ -176,6 +189,16 @@ export default defineComponent({
|
||||
onInput();
|
||||
}
|
||||
|
||||
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
|
||||
|
||||
if (fieldObject === undefined) return undefined;
|
||||
if (fieldSections.length === 1) return fieldObject;
|
||||
return findTree(fieldObject.children, fieldSections.slice(1));
|
||||
}
|
||||
|
||||
function joinElements(first: HTMLElement, second: HTMLElement) {
|
||||
first.innerText += second.innerText;
|
||||
second.remove();
|
||||
@@ -241,7 +264,7 @@ export default defineComponent({
|
||||
return `<span class="text">${part}</span>`;
|
||||
}
|
||||
const fieldKey = part.replaceAll(/({|})/g, '').trim();
|
||||
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
|
||||
const field = findTree(tree.value, fieldKey.split('.'));
|
||||
|
||||
if (!field) return '';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/>
|
||||
<span @click="toggle">
|
||||
{{ field.name }}
|
||||
<v-icon class="required" sup name="star" v-if="field.required" />
|
||||
<v-icon class="required" sup name="star" v-if="field.schema && field.schema.is_nullable === false" />
|
||||
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,9 @@ body {
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--v-list-item-color: var(--foreground-subdued);
|
||||
--v-list-item-color: var(--foreground-subdued) !important;
|
||||
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@at-root {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="v-pagination">
|
||||
<v-button v-if="value !== 1" class="previous" :disabled="disabled" secondary icon small @click="toPrev">
|
||||
<v-button class="previous" :disabled="disabled || value === 1" secondary icon small @click="toPrev">
|
||||
<v-icon name="chevron_left" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && value > Math.ceil(totalVisible / 2)"
|
||||
v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > totalVisible"
|
||||
class="page"
|
||||
@click="toPage(1)"
|
||||
secondary
|
||||
@@ -15,7 +15,9 @@
|
||||
1
|
||||
</v-button>
|
||||
|
||||
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1" class="gap">...</span>
|
||||
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > totalVisible + 1" class="gap">
|
||||
...
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-for="page in visiblePages"
|
||||
@@ -30,12 +32,15 @@
|
||||
{{ page }}
|
||||
</v-button>
|
||||
|
||||
<span v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2)" class="gap">
|
||||
<span
|
||||
v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2) && length > totalVisible + 1"
|
||||
class="gap"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2)"
|
||||
v-if="showFirstLast && value <= length - Math.ceil(totalVisible / 2) && length > totalVisible"
|
||||
:class="{ active: value === length }"
|
||||
class="page"
|
||||
@click="toPage(length)"
|
||||
@@ -46,7 +51,7 @@
|
||||
{{ length }}
|
||||
</v-button>
|
||||
|
||||
<v-button v-if="value !== length" class="next" :disabled="disabled" secondary icon small @click="toNext">
|
||||
<v-button class="next" :disabled="disabled || value === length" secondary icon small @click="toNext">
|
||||
<v-icon name="chevron_right" />
|
||||
</v-button>
|
||||
</div>
|
||||
@@ -139,9 +144,9 @@ body {
|
||||
display: flex;
|
||||
|
||||
.gap {
|
||||
display: none;
|
||||
margin: 0 4px;
|
||||
color: var(--foreground-subdued);
|
||||
display: none;
|
||||
line-height: 2em;
|
||||
|
||||
@include breakpoint(small) {
|
||||
|
||||
@@ -29,15 +29,66 @@
|
||||
<p class="type-label">{{ $t('drag_file_here') }}</p>
|
||||
<p class="type-text">{{ $t('click_to_browse') }}</p>
|
||||
<input class="browse" type="file" @input="onBrowseSelect" />
|
||||
|
||||
<template v-if="fromUrl !== false || fromLibrary !== false">
|
||||
<v-menu showArrow placement="bottom-end">
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon @click="toggle" class="options" name="more_vert" />
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="activeDialog = 'choose'" v-if="fromLibrary">
|
||||
<v-list-item-icon><v-icon name="folder_open" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ $t('choose_from_library') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="activeDialog = 'url'" v-if="fromUrl">
|
||||
<v-list-item-icon><v-icon name="link" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ $t('import_from_url') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<modal-browse
|
||||
collection="directus_files"
|
||||
:active="activeDialog === 'choose'"
|
||||
@update:active="activeDialog = null"
|
||||
@input="setSelection"
|
||||
/>
|
||||
|
||||
<v-dialog :active="activeDialog === 'url'" @toggle="activeDialog = null" :persistent="urlLoading">
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('import_from_url') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-input :placeholder="$t('url')" v-model="url" :disabled="urlLoading" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button :disabled="urlLoading" @click="activeDialog = null" secondary>
|
||||
{{ $t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :loading="urlLoading" @click="importFromURL" :disabled="isValidURL === false">
|
||||
{{ $t('import') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
|
||||
import uploadFiles from '@/utils/upload-files';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import api from '@/api';
|
||||
import useItem from '@/composables/use-item';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse },
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
@@ -47,10 +98,21 @@ export default defineComponent({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
fromUrl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fromLibrary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { uploading, progress, error, upload, onBrowseSelect, done, numberOfFiles } = useUpload();
|
||||
const { onDragEnter, onDragLeave, onDrop, dragging } = useDragging();
|
||||
const { url, isValidURL, loading: urlLoading, error: urlError, importFromURL } = useURLImport();
|
||||
const { setSelection } = useSelection();
|
||||
const activeDialog = ref<'choose' | 'url' | null>(null);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
@@ -63,6 +125,12 @@ export default defineComponent({
|
||||
onBrowseSelect,
|
||||
done,
|
||||
numberOfFiles,
|
||||
activeDialog,
|
||||
url,
|
||||
isValidURL,
|
||||
urlLoading,
|
||||
importFromURL,
|
||||
setSelection,
|
||||
};
|
||||
|
||||
function useUpload() {
|
||||
@@ -146,6 +214,67 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useSelection() {
|
||||
const collection = ref('directus_files');
|
||||
const image = ref<string | null>(null);
|
||||
const { item, error, loading } = useItem(collection, image);
|
||||
|
||||
function setSelection(selection: string[]) {
|
||||
if (selection[0]) {
|
||||
image.value = selection[0];
|
||||
} else {
|
||||
image.value = null;
|
||||
emit('upload', null);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => item.value,
|
||||
(id) => {
|
||||
if (error.value === null && loading.value === false) {
|
||||
emit('upload', item.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { setSelection };
|
||||
}
|
||||
|
||||
function useURLImport() {
|
||||
const url = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const isValidURL = computed(() => {
|
||||
try {
|
||||
new URL(url.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return { url, loading, error, isValidURL, importFromURL };
|
||||
|
||||
async function importFromURL() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.post(`/files/import`, {
|
||||
url: url.value,
|
||||
});
|
||||
|
||||
emit('upload', response.data.data);
|
||||
activeDialog.value = null;
|
||||
url.value = '';
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -214,4 +343,17 @@ export default defineComponent({
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
color: var(--foreground-subdued);
|
||||
cursor: pointer;
|
||||
transition: color var(--medium) var(--transition);
|
||||
}
|
||||
|
||||
.v-upload:hover .options {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,17 +3,22 @@ import { FieldTree } from './types';
|
||||
import { useFieldsStore, useRelationsStore } from '@/stores/';
|
||||
import { Field, Relation } from '@/types';
|
||||
|
||||
export default function useFieldTree(collection: Ref<string>) {
|
||||
export default function useFieldTree(collection: Ref<string>, showHidden = false) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const tree = computed<FieldTree[]>(() => {
|
||||
return fieldsStore
|
||||
.getFieldsForCollection(collection.value)
|
||||
.filter(
|
||||
(field: Field) =>
|
||||
field.meta?.hidden === false && (field.meta?.special || []).includes('alias') === false
|
||||
)
|
||||
.filter((field: Field) => {
|
||||
let shown = (field.meta?.special || []).includes('alias') === false;
|
||||
|
||||
if (showHidden === false && field.meta?.hidden === true) {
|
||||
shown = false;
|
||||
}
|
||||
|
||||
return shown;
|
||||
})
|
||||
.map((field: Field) => parseField(field, []));
|
||||
|
||||
function parseField(field: Field, parents: Field[]) {
|
||||
|
||||
@@ -111,12 +111,6 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
if (isNew.value) {
|
||||
notify({
|
||||
title: i18n.tc('item_create_failed', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value
|
||||
? (primaryKey.value as string).split(',').join(', ')
|
||||
: primaryKey.value,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
@@ -138,9 +132,9 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
.map((err: APIError) => {
|
||||
return err.extensions;
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch, PropType } from '@vue/composition-api';
|
||||
import { defineComponent, ref, watch, PropType, computed } from '@vue/composition-api';
|
||||
import localizedFormat from '@/utils/localized-format';
|
||||
import localizedFormatDistance from '@/utils/localized-format-distance';
|
||||
import i18n from '@/lang';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { parseISO, parse } from 'date-fns';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -26,20 +26,34 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const localValue = computed(() => {
|
||||
if (!props.value) return null;
|
||||
|
||||
if (props.type === 'timestamp') {
|
||||
return parseISO(props.value);
|
||||
} else if (props.type === 'dateTime') {
|
||||
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
|
||||
} else if (props.type === 'date') {
|
||||
return parse(props.value, 'yyyy-MM-dd', new Date());
|
||||
} else if (props.type === 'time') {
|
||||
return parse(props.value, 'HH:mm:ss', new Date());
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const displayValue = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
localValue,
|
||||
async (newValue) => {
|
||||
if (newValue === null) {
|
||||
displayValue.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const date = parseISO(props.value);
|
||||
|
||||
if (props.relative) {
|
||||
displayValue.value = await localizedFormatDistance(date, new Date(), {
|
||||
displayValue.value = await localizedFormatDistance(newValue, new Date(), {
|
||||
addSuffix: true,
|
||||
});
|
||||
} else {
|
||||
@@ -47,7 +61,7 @@ export default defineComponent({
|
||||
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
|
||||
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
|
||||
|
||||
displayValue.value = await localizedFormat(date, format);
|
||||
displayValue.value = await localizedFormat(newValue, format);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span class="user" :class="display">
|
||||
<user-popover v-if="value" :user="value.id">
|
||||
<user-popover v-if="value" :user="value.id">
|
||||
<div class="user" :class="display">
|
||||
<img
|
||||
v-if="(display === 'avatar' || display === 'both') && src"
|
||||
:src="src"
|
||||
@@ -16,8 +16,8 @@
|
||||
:class="{ circle }"
|
||||
/>
|
||||
<span v-if="display === 'name' || display === 'both'">{{ value.first_name }} {{ value.last_name }}</span>
|
||||
</user-popover>
|
||||
</span>
|
||||
</div>
|
||||
</user-popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -148,7 +148,7 @@ export default defineComponent({
|
||||
(htmlColorInput.value?.$el as HTMLElement).getElementsByTagName('input')[0].click();
|
||||
}
|
||||
|
||||
const isValidColor = computed<boolean>(() => rgb.value != null);
|
||||
const isValidColor = computed<boolean>(() => rgb.value !== null);
|
||||
|
||||
const { hsl, rgb, hex } = useColor();
|
||||
|
||||
@@ -199,7 +199,8 @@ export default defineComponent({
|
||||
const newColor = Color(newValue);
|
||||
if (newColor === null || newColor === _rgb.value) return;
|
||||
_rgb.value = newColor;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const rgb = computed<number[]>({
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { defineComponent, ref, watch } from '@vue/composition-api';
|
||||
import { withKnobs, select } from '@storybook/addon-knobs';
|
||||
import readme from './readme.md';
|
||||
import i18n from '@/lang';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
|
||||
export default {
|
||||
title: 'Interfaces / DateTime',
|
||||
decorators: [withPadding, withKnobs],
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
props: {
|
||||
type: {
|
||||
default: select('Type', ['datetime', 'date', 'time'], 'datetime'),
|
||||
},
|
||||
},
|
||||
components: { RawValue },
|
||||
setup(props) {
|
||||
const value = ref('2020-04-28 14:40:00');
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
(newType: string) => {
|
||||
if (newType === 'datetime') {
|
||||
value.value = '2020-04-28 14:40:00';
|
||||
} else if (newType === 'time') {
|
||||
value.value = '14:40:00';
|
||||
} else {
|
||||
// date
|
||||
value.value = '2020-04-28';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 300px;">
|
||||
<interface-datetime
|
||||
v-model="value"
|
||||
:type="type"
|
||||
/>
|
||||
<portal-target multiple name="outlet" />
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
@@ -17,13 +17,13 @@
|
||||
|
||||
<div class="date-selects" v-if="type === 'timestamp' || type === 'dateTime' || type === 'date'">
|
||||
<div class="month">
|
||||
<v-select :placeholder="$t('month')" :items="months" v-model="localValue.month" />
|
||||
<v-select :placeholder="$t('month')" :items="monthItems" v-model="month" />
|
||||
</div>
|
||||
<div class="date">
|
||||
<v-select :placeholder="$t('date')" :items="dates" v-model="localValue.date" />
|
||||
<v-select :placeholder="$t('date')" :items="dateItems" v-model="date" />
|
||||
</div>
|
||||
<div class="year">
|
||||
<v-select :placeholder="$t('year')" :items="years" v-model="localValue.year" allow-other />
|
||||
<v-select :placeholder="$t('year')" :items="yearItems" v-model="year" allow-other />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,19 +32,19 @@
|
||||
<div
|
||||
class="time-selects"
|
||||
v-if="type === 'timestamp' || type === 'dateTime' || type === 'time'"
|
||||
:class="{ seconds: includeSeconds }"
|
||||
:class="{ seconds: includeSeconds, 'use-24': use24 }"
|
||||
>
|
||||
<div class="hour">
|
||||
<v-select :items="hours" v-model="localValue.hours" />
|
||||
<v-select :items="hourItems" v-model="hours" />
|
||||
</div>
|
||||
<div class="minutes">
|
||||
<v-select :items="minutesSeconds" v-model="localValue.minutes" />
|
||||
<v-select :items="minutesSecondItems" v-model="minutes" />
|
||||
</div>
|
||||
<div v-if="includeSeconds" class="seconds">
|
||||
<v-select :items="minutesSeconds" v-model="localValue.seconds" />
|
||||
<v-select :items="minutesSecondItems" v-model="seconds" />
|
||||
</div>
|
||||
<div class="period">
|
||||
<v-select :items="['am', 'pm']" v-model="localValue.period" />
|
||||
<div class="period" v-if="use24 === false">
|
||||
<v-select :items="['am', 'pm']" v-model="period" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
import { defineComponent, ref, watch, computed, reactive, PropType } from '@vue/composition-api';
|
||||
import formatLocalized from '@/utils/localized-format';
|
||||
import { i18n } from '@/lang';
|
||||
import { formatISO, parseISO } from 'date-fns';
|
||||
import { formatISO, parseISO, format, parse } from 'date-fns';
|
||||
|
||||
type LocalValue = {
|
||||
month: null | number;
|
||||
@@ -67,7 +67,6 @@ type LocalValue = {
|
||||
hours: null | number;
|
||||
minutes: null | number;
|
||||
seconds: null | number;
|
||||
period: 'am' | 'pm';
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
@@ -89,121 +88,209 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
use24: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const valueAsDate = computed(() => {
|
||||
if (props.value === null) return null;
|
||||
return parseISO(props.value);
|
||||
});
|
||||
|
||||
const displayValue = ref<string | null>(null);
|
||||
|
||||
syncDisplayValue();
|
||||
|
||||
const localValue = reactive({
|
||||
month: null,
|
||||
date: null,
|
||||
year: null,
|
||||
hours: 9,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
period: 'am',
|
||||
} as LocalValue);
|
||||
|
||||
syncLocalValue();
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue !== null && newValue.length !== 0) {
|
||||
syncLocalValue();
|
||||
syncDisplayValue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => localValue,
|
||||
(newValue) => {
|
||||
if (
|
||||
newValue.year !== null &&
|
||||
String(newValue.year).length === 4 &&
|
||||
newValue.month !== null &&
|
||||
newValue.date !== null &&
|
||||
newValue.hours !== null &&
|
||||
newValue.minutes !== null &&
|
||||
newValue.seconds !== null
|
||||
) {
|
||||
const { year, month, date, hours, minutes, seconds, period } = newValue;
|
||||
|
||||
const asDate = new Date(year, month, date, period === 'am' ? hours : hours + 12, minutes, seconds);
|
||||
|
||||
if(valueAsDate.value?.getTime() != asDate.getTime())
|
||||
emit('input', formatISO(asDate));
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { months, dates, years, hours, minutesSeconds } = useOptions();
|
||||
const { _value, year, month, date, hours, minutes, seconds, period } = useLocalValue();
|
||||
const { yearItems, monthItems, dateItems, hourItems, minutesSecondItems } = useOptions();
|
||||
const { displayValue } = useDisplayValue();
|
||||
|
||||
return {
|
||||
displayValue,
|
||||
months,
|
||||
dates,
|
||||
years,
|
||||
year,
|
||||
month,
|
||||
date,
|
||||
hours,
|
||||
minutesSeconds,
|
||||
minutes,
|
||||
seconds,
|
||||
period,
|
||||
setToNow,
|
||||
localValue,
|
||||
onAMPMInput,
|
||||
yearItems,
|
||||
monthItems,
|
||||
dateItems,
|
||||
hourItems,
|
||||
minutesSecondItems,
|
||||
displayValue,
|
||||
};
|
||||
|
||||
function useLocalValue() {
|
||||
const _value = computed({
|
||||
get() {
|
||||
if (!props.value) return null;
|
||||
|
||||
if (props.type === 'timestamp') {
|
||||
return parseISO(props.value);
|
||||
} else if (props.type === 'dateTime') {
|
||||
return parse(props.value, "yyyy-MM-dd'T'HH:mm:ss", new Date());
|
||||
} else if (props.type === 'date') {
|
||||
return parse(props.value, 'yyyy-MM-dd', new Date());
|
||||
} else if (props.type === 'time') {
|
||||
return parse(props.value, 'HH:mm:ss', new Date());
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
set(newValue: Date | null) {
|
||||
if (newValue === null) return emit('input', null);
|
||||
|
||||
if (props.type === 'timestamp') {
|
||||
emit('input', formatISO(newValue));
|
||||
} else if (props.type === 'dateTime') {
|
||||
emit('input', format(newValue, "yyyy-MM-dd'T'HH:mm:ss"));
|
||||
} else if (props.type === 'date') {
|
||||
emit('input', format(newValue, 'yyyy-MM-dd'));
|
||||
} else if (props.type === 'time') {
|
||||
emit('input', format(newValue, 'HH:mm:ss'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const year = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getFullYear();
|
||||
},
|
||||
set(newYear: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setFullYear(newYear || 0);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const month = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getMonth();
|
||||
},
|
||||
set(newMonth: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setMonth(newMonth || 0);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const date = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getDate();
|
||||
},
|
||||
set(newDate: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setDate(newDate || 1);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const hours = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
const hours = _value.value.getHours();
|
||||
|
||||
if (props.use24 === false) {
|
||||
return hours % 12;
|
||||
}
|
||||
|
||||
return hours;
|
||||
},
|
||||
set(newHours: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setHours(newHours || 0);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const minutes = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getMinutes();
|
||||
},
|
||||
set(newMinutes: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setMinutes(newMinutes || 0);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const seconds = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getSeconds();
|
||||
},
|
||||
set(newSeconds: number | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
newValue.setSeconds(newSeconds || 0);
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const period = computed({
|
||||
get() {
|
||||
if (!_value.value) return null;
|
||||
return _value.value.getHours() >= 12 ? 'pm' : 'am';
|
||||
},
|
||||
set(newAMPM: 'am' | 'pm' | null) {
|
||||
const newValue = _value.value ? new Date(_value.value) : new Date();
|
||||
const current = newValue.getHours() >= 12 ? 'pm' : 'am';
|
||||
|
||||
if (current !== newAMPM) {
|
||||
if (newAMPM === 'am') {
|
||||
newValue.setHours(newValue.getHours() - 12);
|
||||
} else {
|
||||
newValue.setHours(newValue.getHours() + 12);
|
||||
}
|
||||
}
|
||||
|
||||
_value.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
return { _value, year, month, date, hours, minutes, seconds, period };
|
||||
}
|
||||
|
||||
function setToNow() {
|
||||
const date = new Date();
|
||||
|
||||
localValue.month = date.getMonth();
|
||||
localValue.date = date.getDate();
|
||||
localValue.year = date.getFullYear();
|
||||
localValue.hours = date.getHours();
|
||||
localValue.minutes = date.getMinutes();
|
||||
localValue.seconds = date.getSeconds();
|
||||
_value.value = new Date();
|
||||
}
|
||||
|
||||
function syncLocalValue() {
|
||||
if (!valueAsDate.value) return;
|
||||
localValue.month = valueAsDate.value.getMonth();
|
||||
localValue.date = valueAsDate.value.getDate();
|
||||
localValue.year = valueAsDate.value?.getFullYear();
|
||||
localValue.hours = valueAsDate.value?.getHours() % 12;
|
||||
localValue.minutes = valueAsDate.value?.getMinutes();
|
||||
localValue.seconds = valueAsDate.value?.getSeconds();
|
||||
}
|
||||
function useDisplayValue() {
|
||||
const displayValue = ref<string | null>(null);
|
||||
|
||||
async function syncDisplayValue() {
|
||||
if (valueAsDate.value === null) return null;
|
||||
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
|
||||
watch(_value, setDisplayValue);
|
||||
|
||||
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
|
||||
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
|
||||
return { displayValue };
|
||||
|
||||
displayValue.value = await formatLocalized(valueAsDate.value as Date, format);
|
||||
}
|
||||
async function setDisplayValue() {
|
||||
if (!props.value || !_value.value) {
|
||||
displayValue.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
function onAMPMInput(newValue: 'PM' | 'AM') {
|
||||
if (!localValue.hours) return;
|
||||
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
|
||||
|
||||
if (newValue === 'AM') {
|
||||
localValue.hours = localValue.hours - 12;
|
||||
} else {
|
||||
localValue.hours = localValue.hours + 12;
|
||||
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
|
||||
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
|
||||
|
||||
displayValue.value = await formatLocalized(_value.value, format);
|
||||
}
|
||||
}
|
||||
|
||||
function useOptions() {
|
||||
const months = computed(() =>
|
||||
const yearItems = computed(() => {
|
||||
const current = _value.value?.getFullYear() || new Date().getFullYear();
|
||||
const years = [];
|
||||
|
||||
for (let i = current - 5; i <= current + 5; i++) {
|
||||
years.push({
|
||||
text: String(i),
|
||||
value: i,
|
||||
});
|
||||
}
|
||||
|
||||
return years;
|
||||
});
|
||||
|
||||
const monthItems = computed(() =>
|
||||
[
|
||||
i18n.t('months.january'),
|
||||
i18n.t('months.february'),
|
||||
@@ -223,7 +310,7 @@ export default defineComponent({
|
||||
}))
|
||||
);
|
||||
|
||||
const dates = computed(() => {
|
||||
const dateItems = computed(() => {
|
||||
const dates = [];
|
||||
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
@@ -233,24 +320,15 @@ export default defineComponent({
|
||||
return dates;
|
||||
});
|
||||
|
||||
const years = computed(() => {
|
||||
const current = valueAsDate.value?.getFullYear() || new Date().getFullYear();
|
||||
const years = [];
|
||||
for (let i = current - 5; i <= current + 5; i++) {
|
||||
years.push({
|
||||
text: String(i),
|
||||
value: i,
|
||||
});
|
||||
}
|
||||
return years;
|
||||
});
|
||||
|
||||
const hours = computed(() => {
|
||||
const hourItems = computed(() => {
|
||||
const hours = [];
|
||||
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const hoursInADay = props.use24 ? 24 : 12;
|
||||
|
||||
for (let i = 1; i <= hoursInADay; i++) {
|
||||
let hour = String(i);
|
||||
if (hour.length === 1) hour = '0' + hour;
|
||||
|
||||
hours.push({
|
||||
text: hour,
|
||||
value: i,
|
||||
@@ -260,7 +338,7 @@ export default defineComponent({
|
||||
return hours;
|
||||
});
|
||||
|
||||
const minutesSeconds = computed(() => {
|
||||
const minutesSecondItems = computed(() => {
|
||||
const values = [];
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
@@ -275,7 +353,7 @@ export default defineComponent({
|
||||
return values;
|
||||
});
|
||||
|
||||
return { dates, years, months, hours, minutesSeconds };
|
||||
return { yearItems, monthItems, dateItems, hourItems, minutesSecondItems };
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -300,6 +378,14 @@ export default defineComponent({
|
||||
&.seconds {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
&.use-24 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
&.seconds {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month {
|
||||
|
||||
@@ -21,6 +21,18 @@ export default defineInterface(({ i18n }) => ({
|
||||
default_value: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'use24',
|
||||
name: i18n.t('interfaces.datetime.use_24'),
|
||||
type: 'boolean',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
schema: {
|
||||
default_value: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
recommendedDisplays: ['datetime'],
|
||||
}));
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('upload_from_device') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-upload @upload="onUpload" />
|
||||
<v-upload @upload="onUpload" from-url />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="activeDialog = null" secondary>{{ $t('cancel') }}</v-button>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<v-dialog v-model="showUpload">
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('upload_file') }}</v-card-title>
|
||||
<v-card-text><v-upload @upload="onUpload" multiple /></v-card-text>
|
||||
<v-card-text><v-upload @upload="onUpload" multiple from-url /></v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
/>
|
||||
<file-lightbox v-model="lightboxActive" :id="image.id" />
|
||||
</div>
|
||||
<v-upload v-else @upload="setImage" />
|
||||
<v-upload v-else @upload="setImage" from-library from-url />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,7 @@ import formatFilesize from '@/utils/format-filesize';
|
||||
import i18n from '@/lang';
|
||||
import FileLightbox from '@/views/private/components/file-lightbox';
|
||||
import ImageEditor from '@/views/private/components/image-editor';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import getRootPath from '@/utils/get-root-path';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceManyToMany from './many-to-many.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'many-to-many',
|
||||
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
|
||||
component: InterfaceManyToMany,
|
||||
relationship: 'm2m',
|
||||
types: ['alias'],
|
||||
options: [
|
||||
{
|
||||
field: 'fields',
|
||||
type: 'json',
|
||||
name: i18n.tc('field', 0),
|
||||
meta: {
|
||||
interface: 'tags',
|
||||
width: 'full',
|
||||
options: {
|
||||
placeholder: i18n.t('readable_fields_copy'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
}));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:interface="header.field.interface"
|
||||
:interface-options="header.field.interfaceOptions"
|
||||
:type="header.field.type"
|
||||
:collection="relatedCollection.collection"
|
||||
:collection="junctionCollection"
|
||||
:field="header.field.field"
|
||||
/>
|
||||
</template>
|
||||
|
||||
73
app/src/interfaces/many-to-many/options.vue
Normal file
73
app/src/interfaces/many-to-many/options.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="junctionCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select :collection="junctionCollection" v-model="fields" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fields = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
},
|
||||
set(newFields: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const junctionRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return junctionRelation?.many_collection || null;
|
||||
});
|
||||
|
||||
return { fields, junctionCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceManyToOne from './many-to-one.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'many-to-one',
|
||||
@@ -9,16 +10,6 @@ export default defineInterface(({ i18n }) => ({
|
||||
component: InterfaceManyToOne,
|
||||
types: ['uuid', 'string', 'text', 'integer', 'bigInteger'],
|
||||
relationship: 'm2o',
|
||||
options: [
|
||||
{
|
||||
field: 'template',
|
||||
name: i18n.t('interfaces.many-to-one.display_template'),
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'text-input',
|
||||
},
|
||||
},
|
||||
],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
}));
|
||||
|
||||
71
app/src/interfaces/many-to-one/options.vue
Normal file
71
app/src/interfaces/many-to-one/options.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<v-notice class="full" type="warning" v-if="collection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('interfaces.many-to-one.display_template') }}</p>
|
||||
<v-field-template :collection="relatedCollection" v-model="template" :depth="2"></v-field-template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types/relations';
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
const template = computed({
|
||||
get() {
|
||||
return props.value?.template;
|
||||
},
|
||||
set(newTemplate: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
template: newTemplate,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const relation = props.relations.find(
|
||||
(relation) => relation.many_collection === props.collection && relation.many_field === field
|
||||
);
|
||||
return relation?.one_collection || null;
|
||||
});
|
||||
|
||||
return { template, relatedCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceOneToMany from './one-to-many.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'one-to-many',
|
||||
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
|
||||
component: InterfaceOneToMany,
|
||||
types: ['alias'],
|
||||
relationship: 'o2m',
|
||||
options: [
|
||||
{
|
||||
field: 'fields',
|
||||
type: 'json',
|
||||
name: i18n.tc('field', 0),
|
||||
meta: {
|
||||
interface: 'tags',
|
||||
width: 'full',
|
||||
options: {
|
||||
placeholder: i18n.t('interfaces.one-to-many.readable_fields_copy'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
}));
|
||||
|
||||
72
app/src/interfaces/one-to-many/options.vue
Normal file
72
app/src/interfaces/one-to-many/options.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="relatedCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select :collection="relatedCollection" v-model="fields" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field, Relation } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fields = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
},
|
||||
set(newFields: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const relatedRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return relatedRelation?.many_collection || null;
|
||||
});
|
||||
|
||||
return { fields, relatedCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid.scss';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -39,6 +39,7 @@ export default defineComponent({
|
||||
set(newVal: FieldMeta[]) {
|
||||
const fields = newVal.map((meta) => ({
|
||||
field: meta.field,
|
||||
name: meta.field,
|
||||
meta,
|
||||
}));
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@ export default defineComponent({
|
||||
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage, selection };
|
||||
|
||||
function updateValues(index: number, updatedValues: any) {
|
||||
emit(
|
||||
'input',
|
||||
emitValue(
|
||||
props.value.map((item, i) => {
|
||||
if (i === index) {
|
||||
return updatedValues;
|
||||
@@ -82,18 +81,15 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function onSort(sortedItems: any[]) {
|
||||
emit('input', sortedItems);
|
||||
emitValue(sortedItems);
|
||||
}
|
||||
|
||||
function removeItem(row: any) {
|
||||
selection.value = [];
|
||||
if (props.value) {
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((existingItem) => existingItem !== row)
|
||||
);
|
||||
emitValue(props.value.filter((existingItem) => existingItem !== row));
|
||||
} else {
|
||||
emit('input', null);
|
||||
emitValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +105,19 @@ export default defineComponent({
|
||||
selection.value = [props.value?.length || 0];
|
||||
|
||||
if (props.value !== null) {
|
||||
emit('input', [...props.value, newDefaults]);
|
||||
emitValue([...props.value, newDefaults]);
|
||||
} else {
|
||||
emit('input', [newDefaults]);
|
||||
emitValue([newDefaults]);
|
||||
}
|
||||
}
|
||||
|
||||
function emitValue(value: null | any[]) {
|
||||
if (value === null || value.length === 0) {
|
||||
return emit('input', null);
|
||||
}
|
||||
|
||||
return emit('input', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,13 @@ export default defineComponent({
|
||||
const fieldsStore = useFieldsStore();
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const { relations, translationsCollection, languagesCollection, languageField, translationsPrimaryKeyField } = useRelation();
|
||||
const {
|
||||
relations,
|
||||
translationsCollection,
|
||||
languagesCollection,
|
||||
languageField,
|
||||
translationsPrimaryKeyField,
|
||||
} = useRelation();
|
||||
|
||||
const {
|
||||
languages,
|
||||
@@ -84,7 +90,7 @@ export default defineComponent({
|
||||
const { info, primaryKeyField } = useCollection(languagesCollection);
|
||||
const defaultTemplate = info.value?.meta?.display_template;
|
||||
|
||||
return defaultTemplate || props.template || `{{ $${primaryKeyField.value.field} }}`;
|
||||
return props.template || defaultTemplate || `{{ ${primaryKeyField.value.field} }}`;
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -113,10 +119,12 @@ export default defineComponent({
|
||||
const translationsRelation = computed(() => {
|
||||
if (!relations.value || relations.value.length === 0) return null;
|
||||
|
||||
return relations.value.find((relation: Relation) => {
|
||||
return relation.one_collection === props.collection && relation.one_field === props.field;
|
||||
}) || null;
|
||||
})
|
||||
return (
|
||||
relations.value.find((relation: Relation) => {
|
||||
return relation.one_collection === props.collection && relation.one_field === props.field;
|
||||
}) || null
|
||||
);
|
||||
});
|
||||
|
||||
const translationsCollection = computed(() => {
|
||||
if (!translationsRelation.value) return null;
|
||||
@@ -130,9 +138,11 @@ export default defineComponent({
|
||||
const languagesRelation = computed(() => {
|
||||
if (!relations.value || relations.value.length === 0) return null;
|
||||
|
||||
return relations.value.find((relation: Relation) => {
|
||||
return relation.one_collection !== props.collection && relation.one_field !== props.field;
|
||||
}) || null;
|
||||
return (
|
||||
relations.value.find((relation: Relation) => {
|
||||
return relation.one_collection !== props.collection && relation.one_field !== props.field;
|
||||
}) || null
|
||||
);
|
||||
});
|
||||
|
||||
const languagesCollection = computed(() => {
|
||||
@@ -143,9 +153,15 @@ export default defineComponent({
|
||||
const languageField = computed(() => {
|
||||
if (!languagesRelation.value) return null;
|
||||
return languagesRelation.value.many_field;
|
||||
})
|
||||
});
|
||||
|
||||
return { relations, translationsCollection, languagesCollection, languageField, translationsPrimaryKeyField };
|
||||
return {
|
||||
relations,
|
||||
translationsCollection,
|
||||
languagesCollection,
|
||||
languageField,
|
||||
translationsPrimaryKeyField,
|
||||
};
|
||||
}
|
||||
|
||||
function useLanguages() {
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({
|
||||
icon: 'person',
|
||||
component: InterfaceUser,
|
||||
types: ['uuid'],
|
||||
relationship: 'm2o',
|
||||
options: [
|
||||
{
|
||||
field: 'selectMode',
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"only_show_the_file_extension": "Only show the file extension",
|
||||
"textarea": "Textarea",
|
||||
|
||||
"add_field": "Add Field",
|
||||
|
||||
"role_name": "Role Name",
|
||||
|
||||
"db_only_click_to_configure": "Database Only: Click to Configure ",
|
||||
@@ -79,7 +81,8 @@
|
||||
"empty": "Value has to be empty",
|
||||
"nempty": "Value can't be empty",
|
||||
"null": "Value has to be null",
|
||||
"nnull": "Value can't be null"
|
||||
"nnull": "Value can't be null",
|
||||
"required": "Value is required"
|
||||
},
|
||||
|
||||
"all_access": "All Access",
|
||||
@@ -191,6 +194,17 @@
|
||||
|
||||
"not_available_for_type": "Not Available for this Type",
|
||||
|
||||
"create_translations": "Create Translations",
|
||||
|
||||
"auto_generate": "Auto-Generate",
|
||||
"this_will_auto_setup_fields_relations": "This will automatically setup all required fields and relations.",
|
||||
"click_here": "Click here",
|
||||
"to_manually_setup_translations": "to manually setup translations.",
|
||||
|
||||
"click_to_manage_translated_fields": "There are no translated fields yet. Click here to create them. | There is one translated field. Click here to manage it. | There are {count} translated fields. Click here to manage them.",
|
||||
|
||||
"fields_group": "Fields Group",
|
||||
|
||||
"configure_m2o": "Configure your Many-to-One Relationship...",
|
||||
"configure_o2m": "Configure your One-to-Many Relationship...",
|
||||
"configure_m2m": "Configure your Many-to-Many Relationship...",
|
||||
@@ -532,6 +546,8 @@
|
||||
"user_directory": "User Directory",
|
||||
"help_and_docs": "Help & Docs",
|
||||
|
||||
"documentation": "Documentation",
|
||||
|
||||
"card_size": "Card Size",
|
||||
"sort_field": "Sort Field",
|
||||
|
||||
@@ -675,8 +691,12 @@
|
||||
"color": "Color",
|
||||
"circle": "Circle",
|
||||
|
||||
|
||||
|
||||
"empty_item": "Empty Item",
|
||||
|
||||
"log_in_with": "Log In with {provider}",
|
||||
|
||||
"filter": "Filter",
|
||||
"advanced_filter": "Advanced Filter",
|
||||
"operators": {
|
||||
@@ -1036,6 +1056,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"no_fields_in_collection": "There are no fields in \"{collection}\" yet",
|
||||
|
||||
"do_nothing": "Do Nothing",
|
||||
"generate_and_save_uuid": "Generate and Save UUID",
|
||||
"save_current_user_id": "Save Current User ID",
|
||||
@@ -1052,7 +1074,7 @@
|
||||
"connection": "Connection",
|
||||
"contains": "Contains",
|
||||
"continue": "Continue",
|
||||
"continue_as": "<b>{name}</b> is already authenticated. If you recognize this account, press continue.",
|
||||
"continue_as": "<b>{name}</b> is currently authenticated. If you recognize this account, press continue.",
|
||||
"creating_item": "Creating Item",
|
||||
"creating_item_page_title": "Creating Item: {collection}",
|
||||
"creating_role": "Creating Role",
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
"one-to-many": {
|
||||
"one-to-many": "One to Many",
|
||||
"description": "Select multiple related items",
|
||||
"readable_fields_copy": "Select the fields that the user can view"
|
||||
"readable_fields_copy": "Select the fields that the user can view",
|
||||
"no_collection": "The collection could not be found"
|
||||
},
|
||||
"radio-buttons": {
|
||||
"radio-buttons": "Radio Buttons",
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
<template>
|
||||
<div class="layout-cards" :style="{ '--size': size * 40 + 'px' }" ref="layoutElement">
|
||||
<portal to="layout-options">
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.cards.image_source') }}</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.cards.image_source') }}</div>
|
||||
<v-select v-model="imageSource" show-deselect item-value="field" item-text="name" :items="fileFields" />
|
||||
</div>
|
||||
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.cards.title') }}</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.cards.title') }}</div>
|
||||
<v-field-template :collection="collection" v-model="title" />
|
||||
</div>
|
||||
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.cards.subtitle') }}</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.cards.subtitle') }}</div>
|
||||
<v-field-template :collection="collection" v-model="subtitle" />
|
||||
</div>
|
||||
|
||||
<v-detail>
|
||||
<v-detail class="field">
|
||||
<template #title>{{ $t('layout_setup') }}</template>
|
||||
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.cards.image_fit') }}</div>
|
||||
<v-select
|
||||
v-model="imageFit"
|
||||
:disabled="imageSource === null"
|
||||
:items="[
|
||||
{
|
||||
text: $t('layouts.cards.crop'),
|
||||
value: 'crop',
|
||||
},
|
||||
{
|
||||
text: $t('layouts.cards.contain'),
|
||||
value: 'contain',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div class="nested-options">
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.cards.image_fit') }}</div>
|
||||
<v-select
|
||||
v-model="imageFit"
|
||||
:disabled="imageSource === null"
|
||||
:items="[
|
||||
{
|
||||
text: $t('layouts.cards.crop'),
|
||||
value: 'crop',
|
||||
},
|
||||
{
|
||||
text: $t('layouts.cards.contain'),
|
||||
value: 'contain',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('fallback_icon') }}</div>
|
||||
<interface-icon v-model="icon" />
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('fallback_icon') }}</div>
|
||||
<interface-icon v-model="icon" />
|
||||
</div>
|
||||
</div>
|
||||
</v-detail>
|
||||
</portal>
|
||||
@@ -345,10 +347,12 @@ export default defineComponent({
|
||||
fields.push('type');
|
||||
}
|
||||
|
||||
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
|
||||
if (sort.value) {
|
||||
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
|
||||
|
||||
if (fields.includes(sortField) === false) {
|
||||
fields.push(sortField);
|
||||
if (fields.includes(sortField) === false) {
|
||||
fields.push(sortField);
|
||||
}
|
||||
}
|
||||
|
||||
const titleSubtitleFields: string[] = [];
|
||||
@@ -400,6 +404,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.layout-cards {
|
||||
padding: var(--content-padding);
|
||||
@@ -465,4 +470,8 @@ export default defineComponent({
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nested-options {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,13 +85,13 @@ export default defineComponent({
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const type = computed(() => {
|
||||
if (props.file === null) return null;
|
||||
if (!props.file || !props.file.type) return null;
|
||||
if (props.file.type.startsWith('image')) return null;
|
||||
return props.file.type.split('/')[1];
|
||||
});
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (props.file === null) return null;
|
||||
if (!props.file || !props.file.type) return null;
|
||||
if (props.file.type.startsWith('image') === false) return null;
|
||||
if (props.file.type.includes('svg')) return null;
|
||||
|
||||
@@ -105,7 +105,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const svgSource = computed(() => {
|
||||
if (props.file === null) return null;
|
||||
if (!props.file || !props.file.type) return null;
|
||||
if (props.file.type.startsWith('image') === false) return null;
|
||||
if (props.file.type.includes('svg') === false) return null;
|
||||
|
||||
@@ -137,6 +137,7 @@ export default defineComponent({
|
||||
if (props.selectMode === true) {
|
||||
toggleSelection();
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
router.push(props.to, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="layout-tabular">
|
||||
<portal to="layout-options">
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.tabular.spacing') }}</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.tabular.spacing') }}</div>
|
||||
<v-select
|
||||
v-model="tableSpacing"
|
||||
:items="[
|
||||
@@ -22,8 +22,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="layout-option">
|
||||
<div class="option-label">{{ $t('layouts.tabular.fields') }}</div>
|
||||
<div class="field">
|
||||
<div class="type-label">{{ $t('layouts.tabular.fields') }}</div>
|
||||
<draggable v-model="activeFields" handle=".drag-handle" :set-data="hideDragImage">
|
||||
<v-checkbox
|
||||
v-for="field in activeFields"
|
||||
|
||||
71
app/src/modules/docs/components/navigation-item.vue
Normal file
71
app/src/modules/docs/components/navigation-item.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<v-list-item v-if="section.children === undefined" :to="section.to" :dense="dense">
|
||||
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ section.name }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<div v-else-if="section.flat === true">
|
||||
<v-divider></v-divider>
|
||||
<navigation-list-item
|
||||
v-for="(childSection, index) in section.children"
|
||||
:key="index"
|
||||
:section="childSection"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
<v-list-group v-else>
|
||||
<template #activator>
|
||||
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ section.name }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
<navigation-list-item
|
||||
v-for="(childSection, index) in section.children"
|
||||
:key="index"
|
||||
:section="childSection"
|
||||
dense
|
||||
/>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from '@vue/composition-api';
|
||||
import { Section } from './sections';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'navigation-list-item',
|
||||
props: {
|
||||
section: {
|
||||
type: Object as PropType<Section>,
|
||||
default: null,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.version {
|
||||
.v-icon {
|
||||
color: var(--foreground-subdued);
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
::v-deep .type-text {
|
||||
color: var(--foreground-subdued);
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
&:hover {
|
||||
.v-icon {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
::v-deep .type-text {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
app/src/modules/docs/components/navigation.vue
Normal file
18
app/src/modules/docs/components/navigation.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<v-list nav>
|
||||
<navigation-item v-for="item in sections" :key="item.to" :section="item"></navigation-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import NavigationItem from './navigation-item.vue';
|
||||
import sections from './sections';
|
||||
|
||||
export default defineComponent({
|
||||
components: { NavigationItem },
|
||||
setup() {
|
||||
return { sections };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
60
app/src/modules/docs/components/sections.ts
Normal file
60
app/src/modules/docs/components/sections.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type Section = {
|
||||
name: string;
|
||||
to: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
sectionIcon?: string;
|
||||
children?: Section[];
|
||||
default?: string;
|
||||
flat?: boolean;
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
icon: 'bubble_chart',
|
||||
name: 'Getting Started',
|
||||
to: '/docs/getting-started',
|
||||
children: [
|
||||
{
|
||||
name: 'Introduction',
|
||||
to: '/docs/getting-started/introduction',
|
||||
},
|
||||
{
|
||||
name: 'Installation',
|
||||
to: '/docs/getting-started/installation',
|
||||
},
|
||||
{
|
||||
name: 'Contributing',
|
||||
to: '/docs/getting-started/contributing',
|
||||
},
|
||||
{
|
||||
name: 'Troubleshooting',
|
||||
to: '/docs/getting-started/trouble',
|
||||
children: [
|
||||
{
|
||||
name: 'Technical Support',
|
||||
to: '/docs/getting-started/trouble/tech-support',
|
||||
},
|
||||
{
|
||||
name: 'Premium Support',
|
||||
to: '/docs/getting-started/trouble/prem-support',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'school',
|
||||
name: 'Concepts',
|
||||
to: '/docs/concepts',
|
||||
default: 'readme',
|
||||
},
|
||||
{
|
||||
icon: 'format_list_numbered',
|
||||
name: 'Guides',
|
||||
to: '/docs/guides',
|
||||
default: 'readme',
|
||||
},
|
||||
];
|
||||
|
||||
export default sections;
|
||||
59
app/src/modules/docs/index.ts
Normal file
59
app/src/modules/docs/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineModule } from '@/modules/define';
|
||||
import Docs from './routes/docs.vue';
|
||||
import sections, { Section } from './components/sections';
|
||||
import { Route } from 'vue-router';
|
||||
|
||||
function urlSplitter(url: string) {
|
||||
if (url.startsWith('/docs')) url = url.replace('/docs', '');
|
||||
if (url.startsWith('/')) url = url.substr(1);
|
||||
return url.split('/');
|
||||
}
|
||||
|
||||
function urlToSection(urlSections: string[], sections: Section[]): Section | null {
|
||||
const section = sections.find((s) => urlSplitter(s.to).pop() === urlSections[0]);
|
||||
|
||||
if (section === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (urlSections.length === 1) {
|
||||
let finalSection = section;
|
||||
while (finalSection.children !== undefined) {
|
||||
finalSection = finalSection.children[0];
|
||||
}
|
||||
if (section.icon) finalSection.icon = section.icon;
|
||||
return finalSection;
|
||||
}
|
||||
|
||||
if (section.children === undefined) return null;
|
||||
|
||||
const sectionDeep = urlToSection(urlSections.slice(1), section.children);
|
||||
|
||||
if (
|
||||
sectionDeep !== null &&
|
||||
sectionDeep.icon === undefined &&
|
||||
sectionDeep.sectionIcon === undefined &&
|
||||
section.icon !== undefined
|
||||
)
|
||||
sectionDeep.sectionIcon = section.icon;
|
||||
return sectionDeep;
|
||||
}
|
||||
|
||||
function props(route: Route) {
|
||||
const section = urlToSection(urlSplitter(route.path), sections);
|
||||
return { section };
|
||||
}
|
||||
|
||||
export default defineModule(({ i18n }) => ({
|
||||
id: 'docs',
|
||||
name: i18n.t('documentation'),
|
||||
icon: 'info',
|
||||
routes: [
|
||||
{
|
||||
path: '/*',
|
||||
component: Docs,
|
||||
props: props,
|
||||
},
|
||||
],
|
||||
order: 20,
|
||||
}));
|
||||
95
app/src/modules/docs/routes/docs.vue
Normal file
95
app/src/modules/docs/routes/docs.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<private-view :title="notFound ? $t('page_not_found') : title">
|
||||
<template #headline>{{ $t('documentation') }}</template>
|
||||
|
||||
<template #title-outer:prepend>
|
||||
<v-button rounded disabled icon>
|
||||
<v-icon :name="isAPIReference ? 'code' : section.icon || section.sectionIcon" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<docs-navigation />
|
||||
</template>
|
||||
|
||||
<div v-if="notFound" class="not-found">
|
||||
<v-info :title="$t('page_not_found')" icon="not_interested">
|
||||
{{ $t('page_not_found_body') }}
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<markdown v-else>{{ mdString }}</markdown>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch, PropType } from '@vue/composition-api';
|
||||
import DocsNavigation from '../components/navigation.vue';
|
||||
import { Section } from '../components/sections';
|
||||
import Markdown from './markdown.vue';
|
||||
import i18n from '@/lang';
|
||||
|
||||
declare module '*.md';
|
||||
|
||||
export default defineComponent({
|
||||
components: { DocsNavigation, Markdown },
|
||||
props: {
|
||||
section: {
|
||||
type: Object as PropType<Section>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const mdString = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
const isAPIReference = computed(() => props.section && props.section.to.startsWith('/docs/api-reference'));
|
||||
const notFound = computed(() => {
|
||||
return props.section === null || error.value !== null;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
return isAPIReference.value ? i18n.t('api_reference') : props.section.name;
|
||||
});
|
||||
|
||||
watch(() => props.section, loadMD, { immediate: true });
|
||||
|
||||
return { isAPIReference, notFound, title, mdString };
|
||||
|
||||
async function loadMD() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
if (props.section === null) {
|
||||
mdString.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadString = props.section.to.replace('/docs', '');
|
||||
|
||||
try {
|
||||
const md = await import(`raw-loader!@directus/docs${loadString}.md`);
|
||||
mdString.value = md.default;
|
||||
} catch (err) {
|
||||
mdString.value = null;
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.v-info {
|
||||
padding: 20vh 0;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20vh 0;
|
||||
}
|
||||
</style>
|
||||
352
app/src/modules/docs/routes/markdown.vue
Normal file
352
app/src/modules/docs/routes/markdown.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="docs selectable">
|
||||
<div class="md" v-html="html" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, watch, PropType, onMounted, onUpdated } from '@vue/composition-api';
|
||||
import marked from 'marked';
|
||||
import highlight from 'highlight.js';
|
||||
|
||||
export default defineComponent({
|
||||
setup(props, { slots }) {
|
||||
const html = ref('');
|
||||
|
||||
onMounted(generateHTML);
|
||||
onUpdated(generateHTML);
|
||||
|
||||
return { html };
|
||||
|
||||
function generateHTML() {
|
||||
if (slots.default === null || !slots.default()?.[0]?.text) {
|
||||
html.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let htmlString = marked(slots.default()[0].text!, {
|
||||
highlight: (code) => highlight.highlightAuto(code).value,
|
||||
});
|
||||
|
||||
const hintRegex = /:::(.*?) (.*?)\n((\s|.)*?):::/gm;
|
||||
|
||||
htmlString = htmlString.replaceAll(
|
||||
hintRegex,
|
||||
(match: string, type: string, title: string, body: string) => {
|
||||
return `<div class="hint ${type}"><p class="hint-title">${title}</p><p class="hint-body">${body}</p></div>`;
|
||||
}
|
||||
);
|
||||
|
||||
html.value = htmlString;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error {
|
||||
padding: 20vh 0;
|
||||
}
|
||||
|
||||
.docs {
|
||||
padding: var(--content-padding);
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
|
||||
.md {
|
||||
max-width: 740px;
|
||||
|
||||
::v-deep {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
|
||||
& > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
position: relative;
|
||||
margin: 20px 0 10px;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
code,
|
||||
tt {
|
||||
margin: 0 2px;
|
||||
padding: 0 5px;
|
||||
white-space: nowrap;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
pre code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre tt {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
h1 tt,
|
||||
h1 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h2 tt,
|
||||
h2 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h3 tt,
|
||||
h3 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h4 tt,
|
||||
h4 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h5 tt,
|
||||
h5 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h6 tt,
|
||||
h6 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
color: var(--foreground-normal);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p,
|
||||
blockquote,
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
li,
|
||||
table,
|
||||
pre {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
& > h2:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
& > h1:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
& > h3:first-child,
|
||||
& > h4:first-child,
|
||||
& > h5:first-child,
|
||||
& > h6:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
a:first-child h1,
|
||||
a:first-child h2,
|
||||
a:first-child h3,
|
||||
a:first-child h4,
|
||||
a:first-child h5,
|
||||
a:first-child h6 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
h1 p,
|
||||
h2 p,
|
||||
h3 p,
|
||||
h4 p,
|
||||
h5 p,
|
||||
h6 p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
li p.first {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 30px;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul :first-child,
|
||||
ol :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ul :last-child,
|
||||
ol :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0 15px;
|
||||
color: var(--foreground-normal);
|
||||
border-left: 4px solid var(--background-normal);
|
||||
}
|
||||
|
||||
blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
table tr {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
border-top: 1px solid var(--background-normal);
|
||||
}
|
||||
|
||||
table tr:nth-child(2n) {
|
||||
background-color: var(--background-page);
|
||||
}
|
||||
|
||||
table tr th {
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
border: 1px solid var(--background-normal);
|
||||
}
|
||||
|
||||
table tr td {
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--background-normal);
|
||||
}
|
||||
|
||||
table tr th :first-child,
|
||||
table tr td :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
table tr th :last-child,
|
||||
table tr td :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.highlight pre {
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--background-normal);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 20px auto;
|
||||
border: none;
|
||||
border-top: 1px solid var(--background-normal);
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: inline-block;
|
||||
padding: 0 16px;
|
||||
background-color: var(--background-subdued);
|
||||
border-left: 8px solid var(--primary);
|
||||
|
||||
&-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.tip {
|
||||
border-left: 8px solid var(--success);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
border-left: 8px solid var(--warning);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-left: 8px solid var(--danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user