Compare commits

...

24 Commits

Author SHA1 Message Date
Constantin Graf
42da2c3397 Set timeout for all GitHub actions 2024-07-01 12:09:30 +02:00
Constantin Graf
62ac23cb1a Fixed tests after adding schema dumps for test database 2024-07-01 12:08:53 +02:00
Constantin Graf
c0c678ac0d Use schema dump only for phpunit test runs 2024-06-30 19:42:10 +02:00
Constantin Graf
c036b77331 Added frankenphp local setup files to .gitignore 2024-06-30 19:41:27 +02:00
Constantin Graf
7b467807d9 Moved from swoole to frankenphp 2024-06-27 16:39:45 +02:00
Gregor Vostrak
2e8b088c59 improve project edit modal: fix enter submit on billable input and add labels 2024-06-24 18:32:43 +02:00
Gregor Vostrak
e69a419551 change cookie session default name to solidtime_session 2024-06-24 18:28:37 +02:00
Gregor Vostrak
a10d0569af fix token refresh on window focus, deactivate webkit playwright tests 2024-06-24 18:23:43 +02:00
Gregor Vostrak
237b3832bb use log driver for mailing in ci pipeline 2024-06-24 18:23:43 +02:00
Gregor Vostrak
eefa7c8ca8 fix focus & click behaviour of time range selector and task project dropdown modal 2024-06-24 18:23:43 +02:00
Gregor Vostrak
fc0a0615cb reenable playwright github action 2024-06-24 18:23:43 +02:00
Gregor Vostrak
3a61d68dc1 rename state change in useCurrentTimeEntry 2024-06-18 18:30:29 +02:00
Gregor Vostrak
0121195e75 focus on description after starting time tracker, ST-254 2024-06-18 18:28:45 +02:00
Gregor Vostrak
0c054bdcf2 improve focus handling for time entry create modal and update end date if start date is after end, fixes ST-250 2024-06-18 17:58:26 +02:00
Gregor Vostrak
96f818cb04 update minor dependencies, update playwright image 2024-06-18 17:29:09 +02:00
Constantin Graf
31ca0419f5 Updated composer dependencies; Changed dependency nwidart/laravel-modules to original repository 2024-06-18 17:01:57 +02:00
dependabot[bot]
78e35222f8 Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:45:22 +02:00
dependabot[bot]
c5b854adb3 Bump braces in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [braces](https://github.com/micromatch/braces).


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:44:59 +02:00
Gregor Vostrak
9f374c7716 improve time picker focus handling and number input, fixes ST-251 2024-06-18 15:48:46 +02:00
Gregor Vostrak
ce8e503faa refresh stores on window focus, fixes ST-262 2024-06-18 13:47:00 +02:00
Gregor Vostrak
79f914d4b6 add partial patches to time entries store after time entry updates to avoid inconsistencies , fixes ST-259 2024-06-18 13:13:39 +02:00
Gregor Vostrak
c4757ee8a9 validate if date is valid before updating the value to prevent invalid dates sent to the server, fixes ST-255 2024-06-18 13:03:37 +02:00
Gregor Vostrak
c0212ec836 make activity graph chart resize on window resize, fixes ST-261 2024-06-18 12:57:07 +02:00
Gregor Vostrak
8f0c9afa1a remove user profile link from signup flow, fixes ST-260 2024-06-18 12:52:25 +02:00
55 changed files with 1727 additions and 1375 deletions

View File

@@ -31,12 +31,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

View File

@@ -4,7 +4,7 @@ APP_ENV=production
APP_DEBUG=false
APP_FORCE_HTTPS=true
SESSION_SECURE_COOKIE=true
OCTANE_SERVER=swoole
OCTANE_SERVER=frankenphp
PAGINATION_PER_PAGE_DEFAULT=500
LOG_CHANNEL=stack

View File

@@ -15,6 +15,8 @@ name: Build - Private
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Check out code"
uses: actions/checkout@v4
@@ -69,7 +71,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql, swoole
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
uses: php-actions/composer@v6
@@ -116,9 +118,11 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
file: docker/prod/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -15,6 +15,8 @@ name: Build - Public
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Check out code"
uses: actions/checkout@v4
@@ -62,10 +64,12 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -6,6 +6,7 @@ on:
jobs:
api_docs:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpstan:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:

View File

@@ -3,6 +3,8 @@ on: push
jobs:
pint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4

View File

@@ -1,10 +1,10 @@
name: Playwright Tests
on:
workflow_dispatch:
on: [push]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
timeout-minutes: 60
services:
mailpit:
image: 'axllent/mailpit:latest'

7
.gitignore vendored
View File

@@ -34,3 +34,10 @@ yarn-error.log
/_ide_helper.php
/.phpstorm.meta.php
/.rnd
/caddy
/frankenphp
/public/frankenphp-worker.php
/data
/config/caddy
/config/composer

View File

@@ -8,7 +8,7 @@
"require": {
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.8.1",
"brick/money": "^0.9.0",
"dedoc/scramble": "dev-main",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.2.0",
@@ -22,12 +22,12 @@
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/flysystem-aws-s3-v3": "^3.0",
"nwidart/laravel-modules": "dev-feature/fixed_path",
"nwidart/laravel-modules": "^11.0.11",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^0.38.0",
"tpetry/laravel-postgresql-enhanced": "^0.39.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
},
"require-dev": {
@@ -102,6 +102,9 @@
"ide-helper": [
"@php artisan ide-helper:generate",
"@php artisan ide-helper:meta"
],
"refresh-schema-dump": [
"@php artisan schema:dump --database=\"pgsql_test\""
]
},
"extra": {
@@ -115,10 +118,6 @@
{
"type": "vcs",
"url": "https://github.com/korridor/scramble"
},
{
"type": "vcs",
"url": "https://github.com/korridor/laravel-modules"
}
],
"config": {

660
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,13 @@ use Laravel\Octane\Events\WorkerErrorOccurred;
use Laravel\Octane\Events\WorkerStarting;
use Laravel\Octane\Events\WorkerStopping;
use Laravel\Octane\Listeners\CloseMonologHandlers;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\DisconnectFromDatabases;
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
@@ -37,7 +40,7 @@ return [
|
*/
'server' => env('OCTANE_SERVER', 'swoole'),
'server' => env('OCTANE_SERVER', 'frankenphp'),
/*
|--------------------------------------------------------------------------

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
use Illuminate\Support\Str;
return [
/*
@@ -130,7 +128,7 @@ return [
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
'solidtime_session'
),
/*

View File

@@ -3,7 +3,7 @@
--
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
SET statement_timeout = 0;
SET lock_timeout = 0;
@@ -947,7 +947,7 @@ ALTER TABLE ONLY public.clients
--
ALTER TABLE ONLY public.organization_invitations
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;
--
@@ -955,7 +955,7 @@ ALTER TABLE ONLY public.organization_invitations
--
ALTER TABLE ONLY public.project_members
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
--
@@ -1019,7 +1019,7 @@ ALTER TABLE ONLY public.tasks
--
ALTER TABLE ONLY public.time_entries
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE CASCADE;
ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT;
--
@@ -1027,7 +1027,7 @@ ALTER TABLE ONLY public.time_entries
--
ALTER TABLE ONLY public.time_entries
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE CASCADE;
ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;
--
@@ -1071,7 +1071,7 @@ ALTER TABLE ONLY public.time_entries
--
-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)
-- Dumped by pg_dump version 15.6 (Ubuntu 15.6-1.pgdg22.04+1)
-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)
SET statement_timeout = 0;
SET lock_timeout = 0;
@@ -1097,30 +1097,33 @@ COPY public.migrations (id, migration, batch) FROM stdin;
6 2016_06_01_000003_create_oauth_refresh_tokens_table 1
7 2016_06_01_000004_create_oauth_clients_table 1
8 2016_06_01_000005_create_oauth_personal_access_clients_table 1
9 2019_05_03_000001_create_customers_table 1
10 2019_05_03_000002_create_subscriptions_table 1
11 2019_05_03_000003_create_subscription_items_table 1
12 2019_05_03_000004_create_transactions_table 1
13 2019_08_19_000000_create_failed_jobs_table 1
14 2019_12_14_000001_create_personal_access_tokens_table 1
15 2020_05_21_100000_create_organizations_table 1
16 2020_05_21_200000_create_organization_user_table 1
17 2020_05_21_300000_create_organization_invitations_table 1
18 2024_01_16_161030_create_sessions_table 1
19 2024_01_20_110218_create_clients_table 1
20 2024_01_20_110439_create_projects_table 1
21 2024_01_20_110444_create_tasks_table 1
22 2024_01_20_110452_create_tags_table 1
23 2024_01_20_110837_create_time_entries_table 1
24 2024_03_26_171253_create_project_members_table 1
25 2024_04_11_150130_create_jobs_table 1
26 2024_04_12_095010_create_cache_table 1
27 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
28 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
29 2024_05_13_171020_rename_table_organization_user_to_members 1
31 2024_05_22_151226_add_client_id_to_time_entries_table 2
36 2024_05_30_175801_add_is_billable_column_to_projects_table 3
37 2024_05_30_175825_add_is_imported_column_to_time_entries_table 3
9 2018_08_08_100000_create_telescope_entries_table 1
10 2019_05_03_000001_create_customers_table 1
11 2019_05_03_000002_create_subscriptions_table 1
12 2019_05_03_000003_create_subscription_items_table 1
13 2019_05_03_000004_create_transactions_table 1
14 2019_08_19_000000_create_failed_jobs_table 1
15 2019_12_14_000001_create_personal_access_tokens_table 1
16 2020_05_21_100000_create_organizations_table 1
17 2020_05_21_200000_create_organization_user_table 1
18 2020_05_21_300000_create_organization_invitations_table 1
19 2024_01_16_161030_create_sessions_table 1
20 2024_01_20_110218_create_clients_table 1
21 2024_01_20_110439_create_projects_table 1
22 2024_01_20_110444_create_tasks_table 1
23 2024_01_20_110452_create_tags_table 1
24 2024_01_20_110837_create_time_entries_table 1
25 2024_03_26_171253_create_project_members_table 1
26 2024_04_11_150130_create_jobs_table 1
27 2024_04_12_095010_create_cache_table 1
28 2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table 1
29 2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table 1
30 2024_05_13_171020_rename_table_organization_user_to_members 1
31 2024_05_22_151226_add_client_id_to_time_entries_table 1
32 2024_05_30_175801_add_is_billable_column_to_projects_table 1
33 2024_05_30_175825_add_is_imported_column_to_time_entries_table 1
34 2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete 1
35 2024_06_10_161831_reset_billable_rates_with_zero_as_value 1
\.
@@ -1128,7 +1131,7 @@ COPY public.migrations (id, migration, batch) FROM stdin;
-- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -
--
SELECT pg_catalog.setval('public.migrations_id_seq', 37, true);
SELECT pg_catalog.setval('public.migrations_id_seq', 35, true);
--

View File

@@ -28,7 +28,9 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=swoole --watch --host=0.0.0.0 --port=80"
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=80 --watch"
XDG_CONFIG_HOME: /var/www/html/config
XDG_DATA_HOME: /var/www/html/data
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
@@ -107,7 +109,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.42.1-jammy
image: mcr.microsoft.com/playwright:v1.44.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -1,120 +1,178 @@
# Accepted values: 8.3 - 8.2
ARG PHP_VERSION=8.3
ARG DOCKER_FILES_BASE_PATH="docker/prod"
ARG FRANKENPHP_VERSION=latest
ARG COMPOSER_VERSION=latest
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
###########################################
# Build frontend assets with NPM
###########################################
#ARG NODE_VERSION=20-alpine
#
#FROM node:${NODE_VERSION} AS build
#
#ENV ROOT=/var/www/html
#
#WORKDIR ${ROOT}
#
#RUN npm config set update-notifier false && npm set progress=false
#
#COPY package*.json ./
#
#RUN if [ -f $ROOT/package-lock.json ]; \
# then \
# npm ci --loglevel=error --no-audit; \
# else \
# npm install --loglevel=error --no-audit; \
# fi
#
#COPY . .
#
#RUN npm run build
###########################################
FROM composer:${COMPOSER_VERSION} AS vendor
FROM php:${PHP_VERSION}-cli-bookworm AS base
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
ARG DOCKER_FILES_BASE_PATH
LABEL maintainer="solidtime <hello@solidtime.io>"
LABEL org.opencontainers.image.title="solidtime"
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for Freelancers and Agencies"
LABEL org.opencontainers.image.description="solidtime is a modern open source timetracker for freelancers and agencies"
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
LABEL org.opencontainers.image.licenses="AGPL"
ARG WWWUSER=1000
ARG WWWGROUP=1000
ARG TZ=UTC
ARG APP_DIR=/var/www/html
ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=swoole \
USER=octane \
ROOT=/var/www/html \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24
TERM=xterm-color \
WITH_HORIZON=false \
WITH_SCHEDULER=false \
OCTANE_SERVER=frankenphp \
USER=octane \
ROOT=${APP_DIR} \
COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=24 \
XDG_CONFIG_HOME=${APP_DIR}/.config \
XDG_DATA_HOME=${APP_DIR}/.data
WORKDIR ${ROOT}
SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
&& echo ${TZ} > /etc/timezone
RUN apt-get update; \
apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
wget \
nano \
ncdu \
ca-certificates \
supervisor \
libsodium-dev \
# Install PHP extensions
&& install-php-extensions \
bz2 \
pcntl \
mbstring \
bcmath \
sockets \
pgsql \
pdo_pgsql \
opcache \
exif \
pdo_mysql \
zip \
intl \
gd \
redis \
rdkafka \
memcached \
igbinary \
ldap \
swoole \
&& apt-get -y autoremove \
&& apt-get clean \
&& docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
apt-get upgrade -yqq; \
apt-get install -yqq --no-install-recommends --show-progress \
apt-utils \
curl \
wget \
nano \
ncdu \
procps \
ca-certificates \
supervisor \
libsodium-dev \
# Install PHP extensions (included with dunglas/frankenphp)
&& install-php-extensions \
bz2 \
pcntl \
mbstring \
bcmath \
sockets \
pgsql \
pdo_pgsql \
opcache \
exif \
pdo_mysql \
zip \
intl \
gd \
redis \
rdkafka \
memcached \
igbinary \
ldap \
&& apt-get -y autoremove \
&& apt-get clean \
&& docker-php-source delete \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
RUN wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64" \
-O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --verbose --no-interaction" > /etc/supercronic/laravel
RUN arch="$(uname -m)" \
&& case "$arch" in \
armhf) _cronic_fname='supercronic-linux-arm' ;; \
aarch64) _cronic_fname='supercronic-linux-arm64' ;; \
x86_64) _cronic_fname='supercronic-linux-amd64' ;; \
x86) _cronic_fname='supercronic-linux-386' ;; \
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
esac \
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
-O /usr/bin/supercronic \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
RUN userdel --remove --force www-data \
&& groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
&& groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER}
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
&& chmod -R a+rw /var/{log,run}
&& chmod -R a+rw ${ROOT} /var/{log,run}
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
USER ${USER}
COPY --chown=${USER}:${USER} --from=vendor /usr/bin/composer /usr/bin/composer
#COPY --chown=${USER}:${USER} composer.json composer.lock ./
#
#RUN composer install \
# --no-dev \
# --no-interaction \
# --no-autoloader \
# --no-ansi \
# --no-scripts \
# --audit
COPY --chown=${USER}:${USER} . .
#COPY --chown=${USER}:${USER} --from=build ${ROOT}/public public
RUN mkdir -p \
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
storage/framework/{sessions,views,cache,testing} \
storage/logs \
bootstrap/cache && chmod -R a+rw storage
COPY --chown=${USER}:${USER} docker/prod/deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} docker/prod/deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
COPY --chown=${USER}:${USER} docker/prod/deployment/start-container /usr/local/bin/start-container
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/supervisor/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
RUN cat .env
RUN php artisan env
RUN php artisan storage:link
# FrankenPHP embedded PHP configuration
COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/php.ini
#RUN composer install \
# --classmap-authoritative \
# --no-interaction \
# --no-ansi \
# --no-dev \
# && composer clear-cache
RUN chmod +x /usr/local/bin/start-container
RUN cat docker/prod/deployment/utilities.sh >> ~/.bashrc
RUN cat ${DOCKER_FILES_BASE_PATH}deployment/utilities.sh >> ~/.bashrc
EXPOSE 8000

View File

@@ -1,128 +0,0 @@
# Laravel Octane Dockerfile
<a href="/LICENSE"><img alt="License" src="https://img.shields.io/github/license/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/releases"><img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/pulls"><img alt="GitHub closed pull requests" src="https://img.shields.io/github/issues-pr-closed/exaco/laravel-octane-dockerfile"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/roadrunner-test.yml/badge.svg"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/swoole-test.yml/badge.svg"></a>
<a href="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/tests.yml"><img alt="GitHub Workflow Status" src="https://github.com/exaco/laravel-octane-dockerfile/actions/workflows/frankenphp-test.yml/badge.svg"></a>
Production-ready Dockerfiles for [Laravel Octane](https://github.com/laravel/octane)
powered web services and microservices.
The Docker configuration provides the following setup:
- PHP 8.2 and 8.3 official Debian-based images
- Preconfigured JIT compiler and OPcache
## Container modes
You can run the Docker container in different modes:
| Mode | `CONTAINER_MODE` | HTTP server |
| --------------------- | ---------------- | ------------------- |
| HTTP Server (default) | `http` | FrankenPHP / Swoole / RoadRunner |
| Horizon | `horizon` | - |
| Scheduler | `scheduler` | - |
| Worker | `worker` | - |
## Usage
### Building Docker image
1. Clone this repository:
```
git clone --depth 1 git@github.com:exaco/laravel-octane-dockerfile.git
```
2. Copy cloned directory content including `deployment` directory, `Dockerfile`, and `.dockerignore` into your Octane powered Laravel project
3. Change the directory to your Laravel project
4. Build your image:
```
docker build -t <image-name>:<tag> -f <your-octane-driver>.Dockerfile .
```
### Running Docker container
```bash
# HTTP mode
docker run -p <port>:80 --rm <image-name>:<tag>
# Horizon mode
docker run -e CONTAINER_MODE=horizon --rm <image-name>:<tag>
# Scheduler mode
docker run -e CONTAINER_MODE=scheduler --rm <image-name>:<tag>
# HTTP mode with Horizon
docker run -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
# HTTP mode with Scheduler
docker run -e WITH_SCHEDULER=true -p <port>:80 --rm <image-name>:<tag>
# HTTP mode with Scheduler and Horizon
docker run -e WITH_SCHEDULER=true -e WITH_HORIZON=true -p <port>:80 --rm <image-name>:<tag>
# Worker mode
docker run -e CONTAINER_MODE=worker -e WORKER_COMMAND="php /var/www/html/artisan foo:bar" --rm <image-name>:<tag>
# Running a single command
docker run --rm <image-name>:<tag> php artisan about
```
## Configuration
### Recommended `Swoole` options in `octane.php`
```php
// config/octane.php
return [
'swoole' => [
'options' => [
'http_compression' => true,
'http_compression_level' => 6, // 1 - 9
'compression_min_length' => 20,
'package_max_length' => 20 * 1024 * 1024, // 20MB
'open_http2_protocol' => true,
'document_root' => public_path(),
'enable_static_handler' => true,
]
]
];
```
## Utilities
Also, some useful Bash functions and aliases are added in `utilities.sh` that maybe help.
## Notes
- Laravel Octane logs request information only in the `local` environment.
- Please be aware of `.dockerignore` content
## ToDo
- [x] Add support for PHP 8.3
- [x] Add support for worker mode
- [ ] Build assets with Bun
- [ ] Create standalone and self-executable app
- [x] Add support for Horizon
- [x] Add support for RoadRunner
- [x] Add support for FrankenPHP
- [x] Add support for the full-stack apps (Front-end assets)
- [ ] Add support `testing` environment and CI
- [x] Add support for the Laravel scheduler
- [ ] Add support for Laravel Dusk
- [x] Support more PHP extensions
- [x] Add tests
- [ ] Add Alpine-based images
## Contributing
Thank you for considering contributing! If you find an issue, or have a better way to do something, feel free to open an
issue, or a PR.
## Credits
- [SMortexa](https://github.com/smortexa)
- [All contributors](https://github.com/exaco/laravel-octane-dockerfile/graphs/contributors)
## License
This repository is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,51 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019
; command=php %(ENV_ROOT)s/artisan octane:start --server=frankenphp --host=localhost --port=443 --admin-port=2019 --https --http-redirect
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,25 @@
version: '2.7'
rpc:
listen: 'tcp://127.0.0.1:6001'
server:
relay: pipes
http:
middleware: [ "static", "gzip", "headers" ]
max_request_size: 20
static:
dir: "public"
forbid: [ ".php", ".htaccess" ]
uploads:
forbid: [".php", ".exe", ".bat", ".sh"]
pool:
allocate_timeout: 10s
destroy_timeout: 10s
supervisor:
max_worker_memory: 128
exec_ttl: 60s
logs:
mode: production
level: debug
encoding: json
status:
address: localhost:2114

View File

@@ -0,0 +1,50 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --rpc-port=6001 --rr-config=%(ENV_ROOT)s/.rr.yaml
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,50 @@
[program:octane]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan octane:start --server=swoole --host=0.0.0.0 --port=8000
user=%(ENV_USER)s
autostart=true
autorestart=true
environment=LARAVEL_OCTANE="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
user=%(ENV_USER)s
autostart=%(ENV_WITH_HORIZON)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/horizon.log
stderr_logfile_maxbytes=200MB
stopwaitsecs=3600
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=true
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[program:clear-scheduler-cache]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=%(ENV_WITH_SCHEDULER)s
autorestart=false
startsecs=0
startretries=1
stdout_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stdout_logfile_maxbytes=200MB
stderr_logfile=%(ENV_ROOT)s/storage/logs/scheduler.log
stderr_logfile_maxbytes=200MB
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -4,6 +4,7 @@ upload_max_filesize = 100M
expose_php = 0
realpath_cache_size = 16M
realpath_cache_ttl = 360
max_input_time = 5
[Opcache]
opcache.enable = 1

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
set -e
container_mode=${CONTAINER_MODE:-http}
container_mode=${CONTAINER_MODE:-"http"}
octane_server=${OCTANE_SERVER}
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
echo "Container mode: $container_mode"
@@ -9,7 +9,7 @@ echo "Container mode: $container_mode"
initialStuff() {
if [ ${auto_db_migrate} = "true" ]; then
echo "Auto database migration enabled."
php artisan migrate --force
php artisan migrate --isolated --force
fi
php artisan optimize:clear; \
php artisan event:cache; \
@@ -22,7 +22,16 @@ if [ "$1" != "" ]; then
elif [ ${container_mode} = "http" ]; then
echo "Octane Server: $octane_server"
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
if [ ${octane_server} = "frankenphp" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
elif [ ${octane_server} = "swoole" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
elif [ ${octane_server} = "roadrunner" ]; then
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
else
echo "Invalid Octane server supplied."
exit 1
fi
elif [ ${container_mode} = "horizon" ]; then
initialStuff
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf

View File

@@ -0,0 +1,14 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface

View File

@@ -1,9 +1,3 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php %(ENV_ROOT)s/artisan horizon
@@ -15,3 +9,6 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopwaitsecs=3600
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,9 +1,3 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:scheduler]
process_name=%(program_name)s_%(process_num)02d
command=supercronic -overlapping /etc/supercronic/laravel
@@ -21,7 +15,12 @@ command=php %(ENV_ROOT)s/artisan schedule:clear-cache
user=%(ENV_USER)s
autostart=true
autorestart=false
startsecs=0
startretries=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -1,9 +1,3 @@
[supervisord]
nodaemon=true
user=%(ENV_USER)s
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:worker]
process_name=%(program_name)s_%(process_num)02d
command=%(ENV_WORKER_COMMAND)s
@@ -14,3 +8,6 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[include]
files=/etc/supervisor/supervisord.conf

View File

@@ -22,11 +22,12 @@ test('test that new manager can be invited', async ({ page }) => {
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.reload();
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
);
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
test('test that new employee can be invited', async ({ page }) => {
@@ -34,11 +35,12 @@ test('test that new employee can be invited', async ({ page }) => {
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.reload();
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
);
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
]);
});
test('test that new admin can be invited', async ({ page }) => {
@@ -46,21 +48,24 @@ test('test that new admin can be invited', async ({ page }) => {
const adminId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.reload();
await expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
);
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
]);
});
test('test that error shows if no role is selected', async ({ page }) => {
await goToOrganizationSettings(page);
const noRoleId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await page.getByRole('button', { name: 'Add', exact: true }).click();
await expect(page.getByRole('main')).toContainText(
'The role field is required.'
);
await Promise.all([
page.getByRole('button', { name: 'Add', exact: true }).click(),
expect(page.getByRole('main')).toContainText(
'The role field is required.'
),
]);
});
// TODO: Add Test for import

1428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,10 +44,10 @@ export default defineConfig({
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
import { twMerge } from 'tailwind-merge';
@@ -14,11 +14,15 @@ const model = defineModel<string | null>({
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
watch(model, (value) => {
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = getDayJsInstance()(newValue);
if (newDate) {
if (newDate.isValid()) {
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('month', newDate.month())

View File

@@ -46,6 +46,7 @@ const billableOptionInfoTexts: { [key in BillableKey]: string } = {
const billableOptionInfoText = computed(() => {
return billableOptionInfoTexts[billableRateSelect.value];
});
const emit = defineEmits(['submit']);
</script>
<template>
@@ -60,7 +61,10 @@ const billableOptionInfoText = computed(() => {
class="sm:max-w-[120px]"
v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" />
<BillableRateInput v-model="billableRate" name="billableRate" />
<BillableRateInput
@keydown.enter="emit('submit')"
v-model="billableRate"
name="billableRate" />
</div>
</div>
<div class="flex items-center text-muted pt-2 pl-1">

View File

@@ -8,12 +8,13 @@ import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/Components/Common/Client/ClientDropdown.vue';
import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -63,10 +64,14 @@ const currentClientName = computed(() => {
<div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center">
<div class="px-3">
<div class="text-center pr-5">
<InputLabel for="color" value="Color" />
<ProjectColorSelector
v-model="project.color"></ProjectColorSelector>
</div>
</div>
<div class="w-full">
<InputLabel for="projectName" value="Project name" />
<TextInput
id="projectName"
ref="projectNameInput"
@@ -79,22 +84,26 @@ const currentClientName = computed(() => {
autocomplete="projectName" />
</div>
<div class="">
<ClientDropdown v-model="project.client_id">
<InputLabel for="client" value="Client" />
<ClientDropdown class="mt-2" v-model="project.client_id">
<template #trigger>
<Badge size="large">
<div
:class="
twMerge('inline-block rounded-full')
"></div>
<span>
{{ currentClientName }}
</span>
<Badge
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">
<UserCircleIcon
class="w-5 text-icon-default"></UserCircleIcon>
<span>
{{ currentClientName }}
</span>
</div>
</Badge>
</template>
</ClientDropdown>
</div>
</div>
<ProjectEditBillableSection
@submit="submit"
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate

View File

@@ -2,9 +2,8 @@
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
@@ -19,6 +18,16 @@ const { createTimeEntry } = useTimeEntriesStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const description = ref<HTMLInputElement | null>(null);
watch(show, (value) => {
if (value) {
nextTick(() => {
description.value?.focus();
});
}
});
const timeEntryDefaultValues = {
description: '',
project_id: null,
@@ -36,12 +45,15 @@ const localStart = ref(
getLocalizedDayJs(timeEntryDefaultValues.start).format()
);
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localStart, (value) => {
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
});
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localEnd, (value) => {
timeEntry.value.end = getLocalizedDayJs(value).utc().format();
});
@@ -53,10 +65,6 @@ async function submit() {
localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();
show.value = false;
}
const projectNameInput = ref<HTMLInputElement | null>(null);
useFocus(projectNameInput, { initialValue: true });
</script>
<template>
@@ -73,11 +81,11 @@ useFocus(projectNameInput, { initialValue: true });
<InputLabel for="description" value="Description" />
<TextInput
id="description"
ref="description"
v-model="timeEntry.description"
@keydown.enter="submit"
type="text"
class="mt-1 block w-full"
autofocus />
class="mt-1 block w-full" />
</div>
<div class="flex items-center justify-between">
<div>

View File

@@ -33,6 +33,7 @@ const open = ref(false);
@changed="
(newStart, newEnd) => emit('changed', newStart, newEnd)
"
focus
:start="start"
:end="end">
</TimeRangeSelector>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/utils/time';
import { twMerge } from 'tailwind-merge';
import { useFocus } from '@vueuse/core';
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
@@ -11,19 +12,31 @@ const model = defineModel<string | null>({
const props = withDefaults(
defineProps<{
size: 'base' | 'large';
focus: boolean;
}>(),
{
size: 'base',
focus: false,
}
);
const hours = computed(() => {
return model.value ? getLocalizedDayJs(model.value).format('HH') : null;
});
const minutes = computed(() => {
return model.value ? getLocalizedDayJs(model.value).format('mm') : null;
});
const hours = ref(
model.value ? getLocalizedDayJs(model.value).format('HH') : null
);
const minutes = ref(
model.value ? getLocalizedDayJs(model.value).format('mm') : null
);
watch(
() => model.value,
() => {
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
);
function updateMinutes(event: Event) {
const target = event.target as HTMLInputElement;
@@ -33,19 +46,31 @@ function updateMinutes(event: Event) {
.set('minutes', Math.min(parseInt(newValue), 59))
.format();
}
minutes.value = model.value
? getLocalizedDayJs(model.value).format('mm')
: null;
}
function updateHours(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
if (!isNaN(parseInt(newValue))) {
if (newValue.endsWith(':')) {
minutesInput.value?.focus();
} else if (!isNaN(parseInt(newValue))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.format();
}
hours.value = model.value
? getLocalizedDayJs(model.value).format('HH')
: null;
}
const hoursInput = ref<HTMLInputElement | null>(null);
const minutesInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['changed']);
useFocus(hoursInput, { initialValue: props.focus });
</script>
<template>
@@ -58,7 +83,8 @@ const emit = defineEmits(['changed']);
)
">
<input
:value="hours"
v-model="hours"
ref="hoursInput"
@input="updateHours"
@keydown.enter="emit('changed')"
@focus="($event.target as HTMLInputElement).select()"
@@ -72,7 +98,8 @@ const emit = defineEmits(['changed']);
" />
<span>:</span>
<input
:value="minutes"
v-model="minutes"
ref="minutesInput"
@keydown.enter="emit('changed')"
@input="updateMinutes"
@focus="($event.target as HTMLInputElement).select()"

View File

@@ -9,6 +9,7 @@ import dayjs from 'dayjs';
const props = defineProps<{
start: string;
end: string | null;
focus?: boolean;
}>();
// The timestamps for the changed event are UTC
@@ -24,7 +25,11 @@ watch(props, () => {
tempEnd.value = getLocalizedDayJs(props.end).format();
});
function updateTimeEntry() {
if (tempStart.value !== props.start || tempEnd.value !== props.end) {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value
? getDayJsInstance()(tempEnd.value).utc().format()
: null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit(
'changed',
getDayJsInstance()(tempStart.value).utc().format(),
@@ -52,6 +57,7 @@ watch(focused, (newValue, oldValue) => {
<div class="space-y-1">
<TimePicker
data-testid="time_entry_range_start"
:focus
@changed="updateTimeEntry"
v-model="tempStart"></TimePicker>
<DatePicker

View File

@@ -314,7 +314,7 @@ const showCreateProject = ref(false);
<span>Add new project</span>
</Badge>
</div>
<Dropdown v-else v-model="open" :closeOnContentClick="true" align="bottom">
<Dropdown v-else v-model="open" :closeOnContentClick="false" align="bottom">
<template #trigger>
<ProjectBadge
ref="projectDropdownTrigger"
@@ -340,6 +340,7 @@ const showCreateProject = ref(false);
:value="searchValue"
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
@click.prevent="searchInput?.focus()"
data-testid="client_dropdown_search"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"

View File

@@ -119,6 +119,13 @@ async function updateTimeRange(newStart: string) {
}
}
}
const startTime = computed(() => {
if (currentTimeEntry.value.start && currentTimeEntry.value.start !== '') {
return currentTimeEntry.value.start;
}
return dayjs().utc().format();
});
</script>
<template>
@@ -142,7 +149,7 @@ async function updateTimeRange(newStart: string) {
<template #content>
<TimeRangeSelector
@changed="updateTimeRange"
:start="currentTimeEntry.start"
:start="startTime"
:end="null">
</TimeRangeSelector>
</template>

View File

@@ -8,7 +8,7 @@ import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
const store = useCurrentTimeEntryStore();
const { currentTimeEntry, now, isActive } = storeToRefs(store);
const { onToggleButtonPress } = store;
const { setActiveState } = store;
const currentTime = computed(() => {
if (now.value && currentTimeEntry.value.start) {
@@ -50,7 +50,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div>
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
@changed="setActiveState"
size="base"></TimeTrackerStartStop>
</div>
</template>

View File

@@ -111,6 +111,7 @@ const option = ref({
<div class="px-2">
<v-chart
class="chart"
:autoresize="true"
:option="option"
style="height: 260px; background-color: transparent" />
</div>

View File

@@ -5,7 +5,7 @@ import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
@@ -31,8 +31,8 @@ dayjs.extend(utc);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, onToggleButtonPress } =
currentTimeEntryStore;
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
watch(isActive, () => {
if (isActive.value) {
@@ -71,9 +71,16 @@ function updateTimeEntry() {
}
}
function onToggleButtonPress(newState: boolean) {
setActiveState(newState);
if (newState) {
currentTimeEntryDescriptionInput.value?.focus();
}
}
function startTimerIfNotActive() {
if (!isActive.value) {
onToggleButtonPress(true);
setActiveState(true);
}
}
@@ -116,6 +123,7 @@ function switchToTimeEntryOrganization() {
<input
placeholder="What are you working on?"
data-testid="time_entry_description"
ref="currentTimeEntryDescriptionInput"
v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry"
@@ -144,7 +152,10 @@ function switchToTimeEntryOrganization() {
"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<TimeTrackerRangeSelector></TimeTrackerRangeSelector>
<TimeTrackerRangeSelector
@keydown.enter="
startTimerIfNotActive
"></TimeTrackerRangeSelector>
</div>
</div>
</div>

View File

@@ -4,24 +4,24 @@ import Banner from '@/Components/Banner.vue';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
import {
Bars3Icon,
ChartBarIcon,
ClockIcon,
Cog6ToothIcon,
CreditCardIcon,
FolderIcon,
HomeIcon,
TagIcon,
UserCircleIcon,
UserGroupIcon,
Bars3Icon,
XMarkIcon,
CreditCardIcon,
} from '@heroicons/vue/20/solid';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/Pages/MainContainer.vue';
import { onMounted, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init';
import { initializeStores, refreshStores } from '@/utils/init';
import {
canUpdateOrganization,
canViewClients,
@@ -32,6 +32,7 @@ import {
import { isBillingActivated } from '@/utils/billing';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/api';
defineProps({
title: String,
@@ -45,6 +46,12 @@ onMounted(async () => {
window.initialDataLoaded = true;
initializeStores();
}
window.onfocus = async () => {
if (!isTokenValid()) {
await fetchToken();
}
refreshStores();
};
});
const page = usePage<{

View File

@@ -49,12 +49,6 @@ const verificationLinkSent = computed(
</PrimaryButton>
<div>
<Link
:href="route('profile.show')"
class="underline text-sm text-muted hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Edit Profile</Link
>
<Link
:href="route('logout')"
method="post"

View File

@@ -5,6 +5,7 @@ import type {
ZodiosQueryParamsByAlias,
} from '@zodios/core';
import { api } from '../../../openapi.json.client';
import { router } from '@inertiajs/vue3';
export type SolidTimeApi = ApiOf<typeof api>;
@@ -123,3 +124,16 @@ export type UpdateOrganizationBody = ZodiosBodyByAlias<
SolidTimeApi,
'updateOrganization'
>;
export async function fetchToken() {
return new Promise((resolve) => {
router.reload({
onFinish: () => {
resolve(null);
},
});
});
}
export function isTokenValid() {
return window.document.cookie.includes('solidtime_session');
}

View File

@@ -8,6 +8,11 @@ import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { canViewClients, canViewMembers } from '@/utils/permissions';
export function initializeStores() {
refreshStores();
useTimeEntriesStore().fetchTimeEntries();
}
export function refreshStores() {
useProjectsStore().fetchProjects();
useTasksStore().fetchTasks();
useTagsStore().fetchTags();
@@ -18,5 +23,4 @@ export function initializeStores() {
if (canViewClients()) {
useClientsStore().fetchClients();
}
useTimeEntriesStore().fetchTimeEntries();
}

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
import { ref } from 'vue';
import axios from 'axios';
import { router } from '@inertiajs/vue3';
import { fetchToken } from '@/utils/api';
export type NotificationType = 'success' | 'error';
@@ -37,16 +38,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
}
async function fetchToken() {
return new Promise((resolve) => {
router.reload({
onFinish: () => {
resolve(null);
},
});
});
}
async function handleApiRequestNotifications<T>(
apiRequest: () => Promise<T>,
successMessage?: string,

View File

@@ -191,7 +191,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
return false;
});
async function onToggleButtonPress(newState: boolean) {
async function setActiveState(newState: boolean) {
if (newState) {
startLiveTimer();
await startTimer();
@@ -214,6 +214,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
startLiveTimer,
stopLiveTimer,
now,
onToggleButtonPress,
setActiveState,
};
});

View File

@@ -78,7 +78,7 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
const response = await handleApiRequestNotifications(
() =>
api.updateTimeEntry(timeEntry, {
params: {
@@ -89,6 +89,9 @@ export const useTimeEntriesStore = defineStore('timeEntries', () => {
'Time entry updated successfully',
'Failed to update time entry'
);
timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry
);
}
}

View File

@@ -5,23 +5,15 @@ declare(strict_types=1);
namespace Tests\Unit\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
class MigrationTest extends \Tests\TestCase
class MigrationTest extends TestCase
{
use RefreshDatabase;
public function test_fresh_migration_and_rollback_runs_successfully(): void
{
Artisan::call('migrate:fresh');
Artisan::call('migrate:rollback');
$this->assertTrue(true);
}
public function testFreshMigrationWithSeederAndRollbackRunsSuccessfully(): void
{
Artisan::call('migrate:fresh --seed');
Artisan::call('migrate:rollback');
$this->assertTrue(true);
$this->artisan('migrate:rollback')
->assertSuccessful();
}
}

View File

@@ -5,23 +5,25 @@ declare(strict_types=1);
namespace Tests\Unit\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
class SeederTest extends \Tests\TestCase
class SeederTest extends TestCase
{
use RefreshDatabase;
public function test_running_the_seeder_multiple_times_runs_successfully(): void
{
Artisan::call('db:seed');
Artisan::call('db:seed');
$this->assertTrue(true);
$this->artisan('db:seed')
->assertSuccessful();
$this->artisan('db:seed')
->assertSuccessful();
}
public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void
{
Artisan::call('migrate:fresh --seed');
Artisan::call('migrate:rollback');
$this->assertTrue(true);
$this->artisan('db:seed')
->assertSuccessful();
$this->artisan('migrate:rollback')
->assertSuccessful();
}
}